104 Commits

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

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

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

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

Defaults aligned across all three config sources (backend default template,
quick-create preset, studio unified config): 2 positions × 4x equity
notional at 10x leverage ≈ 80% margin when both are open — concentrated per
the operator's request. Live strategy updated via relaunch; loader log
confirms maxPos=2 posRatio=4.0.
2026-07-10 19:13:12 +09:00
tinkle-community
8c8cd9b61f feat: show entry price and notional size in the live positions panel
The panel only showed symbol/side/leverage/PnL — entry and position size
are the first things a trader checks. Side and leverage share a column to
make room; prices format with magnitude-aware precision (64,187 · 184.2 ·
0.0067); size is |qty| × mark notional.
2026-07-10 18:56:15 +09:00
tinkle-community
d84e22ab82 docs: describe the real decision data stack in all READMEs
'candles, EMA/MACD/RSI/ATR/Bollinger' undersold what the model actually
reads. All seven READMEs (en, zh-CN, ja, ko, ru, uk, vi) now describe the
Claw402.ai · Vergex stack: the live signal board with directional bias and
signal strength, per-symbol Signal Lab deep signals, cost-basis and
liquidation heatmaps, and real-time net flow — cross-checked against raw
candles and the trader's own track record.
2026-07-07 23:26:12 +09:00
tinkle-community
221cf3d61d docs: sync all six i18n READMEs with the rewritten English README
zh-CN, ja, ko, ru, uk, vi fully retranslated to mirror the new English
README section-for-section: intro, first-run steps, exchange table, risk
runtime table, terminal overview, Claw402 model access, markets,
architecture, install (Linux/macOS, Railway, Docker, Windows, source),
docs/community/contributing, and the risk disclaimer.

Verified across all six: identical section structure (13 headings),
byte-identical architecture diagram and code blocks, every relative link
resolves (banner, exchange icons, screenshots, docs pages — zh-CN links the
zh-CN getting-started/FAQ/troubleshooting variants), correct language
selector rows.
2026-07-07 23:20:02 +09:00
tinkle-community
390db72cdb feat: remove the last beginner walls — USDC sourcing help, reachable deposit page, first-run reassurance
- The /welcome deposit page (QR + auto-refreshing balance + key backup) was
  gated behind a legacy 'beginner mode' localStorage flag that nothing sets
  anymore — every deposit link silently bounced to /traders. Gate removed:
  any signed-in user reaches it.
- 'Don't have USDC yet?' helper on the deposit page: buy on Binance/OKX/
  Coinbase, withdraw picking the Base network, arrives in ~1 minute. This is
  the wall every true newcomer hits first.
- Autopilot step 3 now says exactly where trading money goes
  (app.hyperliquid.xyz → Deposit, USDC on Arbitrum).
- Register page: 'NEW_USER ONBOARDING / Initializing Registration Sequence'
  jargon replaced with what actually happens next (guided launch, ~$13,
  five minutes).
- Dashboard shows a calm first-run note while a fresh autopilot runs its
  first cycle ('reading the whole market — first decision lands in a minute
  or two'), so the initial silence doesn't read as broken.
- Hyperliquid connect steps renamed to plain language ('Approve it in your
  wallet (trade-only, cannot withdraw)' etc.).
2026-07-07 23:03:20 +09:00
tinkle-community
c1b660bb45 feat: beginner-first funnel — plain-language guidance from landing to first trade
A newcomer who can't get from install to a running trade in minutes churns.
This pass removes every jargon wall on that path:

- Landing hero: primary CTA is now 'START THE AUTOPILOT' (routes into the
  guided flow; logged-out users get the guest walkthrough) with a
  plain-language promise line — self-hosted, ~$13 to start, no API keys.
  'CREATE STOCK TRADER' demoted to a 'SEE IT WORK' scroll button.
- Deploy section: 'high-frequency trading node / sandboxed execution env'
  copy replaced with plain words plus the first-five-minutes checklist
  (register → fund $1 + $12 → press start).
- Config page: the guided launch panel now renders FIRST for anyone without
  a running autopilot; the config grid becomes detail below. Steps renamed
  to 'Step 1 · Fund the AI wallet ($1+)' etc., each explained in one plain
  sentence with explicit dollar amounts and safety notes ('it can never
  withdraw your money').
- Every AI-wallet action (deposit, create, preflight fix-links) now lands on
  the /welcome page with the QR code and auto-refreshing balance instead of
  the key-config modal.
- README: first-run three-step guide under the install command (banner asset
  included); six dead landing components removed.

Tests: 128 web tests green; funnel click-verified logged-out and logged-in.
2026-07-07 20:22:59 +09:00
tinkle-community
c53a563bff feat: data-driven autopilot profitability tuning + edge profile panel
Analysis of 410 live closed trades found the edge is real but was being
destroyed by execution: gross +$267 vs $245 fees; trades held <1h were net
negative (the <15m bucket alone: -$48 on $66 fees) while 1h+ holds carried
+$78; shorts lost $72 while longs made $94 — and both the prompt and the
engine were manufacturing those losers.

Changes, each tied to the data:
- Prompt: removed the 'MUST open at least one long AND one short every
  cycle' mandate (forced weak-signal shorts); direction is now data-driven
  with 'never open just to balance the book'. Added fee-awareness (round
  trip ≈ 0.1% notional, require expected move ≥ 3x cost) and aligned the
  hold discipline with the backend throttle.
- Forced book-balance opens now require |board z-score| ≥ 0.75 — the engine
  previously force-opened full-size 10x positions on near-neutral signals
  with hardcoded confidence 70. DirectionalCandidates now carries scores.
- Min AI-managed hold raised 45m → 60m (the 15-60m bucket still bled after
  the earlier throttle landed).
- Legacy prompt hygiene: vergex path drops long-only-era custom prompts and
  zh-era configs fall back wholesale to built-in English sections — fixes
  the two long-failing kernel prompt tests.
- New Edge Profile dashboard panel: net after fees by hold-time bucket and
  side, computed from recent closed trades, with an automatic takeaway line
  — the fee/churn regression detector, always visible. Fixed the
  HistoricalPosition timestamp types (epoch ms, not strings).
2026-07-07 19:21:49 +09:00
tinkle-community
e593874912 docs: add Autopilot pipeline and data-source entries to FAQ
Two entries the FAQ was missing: the strategy's four-stage decision funnel
(Claw402 ranking universe → Signal Lab + cost/liq verification → 15m candle
timing → confidence/risk-reward gated sizing, plus the hard risk-control
layer outside the AI) and a breakdown of every data source with which parts
are paid per call from the AI fee wallet.
2026-07-06 15:13:50 +09:00
tinkle-community
e9873b5b86 feat: rewrite FAQ with current product content and cream terminal design
Content: replaces the stale i18n-keyed answers (manual API-key setup, old
Binance-era flows) with 34 answers matching the shipping product — Autopilot
launch chain, AI fee wallet and Hyperliquid agent authorization, preflight
checks and minimums, safe mode and fee-wallet-empty recovery, dashboard
metric semantics (realized vs unrealized, fee drag, real-baseline drawdown),
security model, self-hosting, and contributing. English-only, matching the UI.

Format: FAQ answers are now typed content blocks (paragraphs, bullets,
numbered steps, notes, link chips) rendered generically — the two giant
hardcoded JSX special cases are gone. Restyled from the broken dark
glassmorphism (unreadable prose-invert on the cream theme) to the terminal
design language: small-caps mono category strips, hairline dividers, gold
accents, code chips, bordered TOC with active tracking. Search filters the
full block text; ~480 lines of dead faq* translation keys removed (faqNav
kept for the nav label).
2026-07-06 14:31:26 +09:00
tinkle-community
026c5d3089 fix: dashboard metrics — real drawdown baseline, realized/unrealized split, risk radar fields
- Max drawdown was double-broken: the backend built the equity curve on a
  hardcoded $10k baseline (understating a small account's drawdown ~20x) and
  the frontend multiplied the already-percent value by 100 again (0.87% shown
  as -86.9%). The curve now starts from the trader's real initial balance
  (10k fallback when unknown) and the UI renders the percent once; the demo
  engine and type docs are aligned to percent semantics.
- Header now separates equity-based 'Total P/L (incl. unrealized)' from
  'Realized P/L (closed trades)' so it no longer contradicts profit factor /
  win rate, and the stats strip shows the fee-drag chain
  (gross - fees = net) plus a per-trade-labelled sharpe.
- Risk radar read a non-existent account field (total_unrealized_profit) so
  unrealized PnL always showed $0; small PnLs also render with cents now.
- Nav 'Connect Hyperliquid' turns into a green connected chip when the server
  already holds a fully-authorized exchange, instead of nagging forever from
  a browser without the localStorage flow state.
2026-07-06 00:01:44 +09:00
tinkle-community
ee5917adc6 fix: raise timeout for trader lifecycle calls to survive slow stop/restart
Live click-through showed PUT /traders/:id taking 33.8s (stopping the running
instance waits for its in-flight cycle and monitor goroutines) while the axios
default timeout is 30s — the frontend aborted a request that succeeded
server-side and reported a false launch failure. Create/update/start now use a
120s ceiling.
2026-07-05 23:42:35 +09:00
tinkle-community
9b70455e7f fix: report accurate is_running after trader update to stop redundant start
Live verification of the relaunch (update) branch surfaced this: the update
response omitted is_running, so the launcher fired a redundant start that got
400 'Trader is already running', showing an error toast and skipping the
dashboard navigation even though the relaunch succeeded.

- PUT /traders/:id now reports is_running (a running trader is restarted with
  the new config, so it stays running).
- The already-running rejection carries error_key trader.start.already_running
  and the shared launcher treats it as success (launch is idempotent).
2026-07-05 23:36:53 +09:00
tinkle-community
a744328313 feat: unify autopilot launch on server preflight with live balance polling
- New shared launch module (web/src/lib/launch): preflight client, model/
  exchange resolution, and a single launchAutopilot orchestrator used by both
  Strategy Studio and the guided launch panel — the two previously duplicated
  and divergent launch implementations are gone.
- Preflight runs BEFORE any strategy mutation, so a failed launch no longer
  rewrites/activates the strategy as a side effect; readiness and minimums
  come from the server (live balances) instead of stale client caches.
- Guided panel polls preflight every 20s so deposits are detected without
  manual refresh; step metas show live balances and required minimums; the
  beginner wallet page polls its balance while the deposit screen is open.
- Every failure now routes to a guided fix: hyperliquid-funds gets its own
  setup anchor (scrolls to the launch panel), start rejections surface the
  server's preflight reason (including manual restarts from the trader list),
  and structured error bodies flow through ApiError.errorData.
- Terminal dashboard shows a persistent runtime banner when the AI fee wallet
  runs low/empty or the trader enters safe mode.
- Vitest coverage for setup-target routing, readiness helpers, and the
  launch orchestration order (no side effects before preflight).
2026-07-05 20:38:51 +09:00
tinkle-community
b17d97effc feat: server-side launch preflight with structured checks and runtime AI wallet health
- POST /api/launch/preflight and GET /api/traders/:id/preflight run every
  launch prerequisite (AI model credential, claw402 wallet key + Base USDC
  balance, strategy, exchange config, live account state, funding minimums)
  and return a structured checklist with stable codes; minimums (1 USDC AI
  fee / 12 USDC trading) now live server-side and are exposed in the response.
- POST /traders/:id/start enforces the preflight (400 with error_key
  trader.start.preflight_failed + full checklist; ?force=true to override).
  Funding gates use max(available, equity) so restarting a bot with deployed
  margin is not blocked; indeterminate probes (RPC outage) degrade to
  warnings instead of blocking.
- Run loop now surfaces runtime health via GetStatus: safe_mode(+reason) and
  ai_wallet_status/balance (ok|low|empty|unknown), including typed detection
  of claw402 ErrInsufficientFunds instead of burying it in logs; safe-mode
  state is mutex-guarded for API readers.
- Onboarding wallet endpoints switch to the cached, error-aware balance query
  and report balance_status so the UI can tell an RPC outage from an empty
  wallet.
2026-07-05 20:38:38 +09:00
tinkle-community
e61f12d307 chore: bump vite, form-data, @babel/core, js-yaml to patched versions
Resolves all open Dependabot alerts (2 high, 2 moderate, 1 low):
vite 6.4.3, form-data 4.0.6, @babel/core 7.29.7, js-yaml 4.3.0.
All within existing semver ranges; build and 111 tests pass.
2026-07-05 19:23:04 +09:00
tinkle-community
30f3feb586 feat: verify wallet and exchange balances before autopilot launch
Requires the Claw402 fee wallet to exist with at least 1 USDC on Base
and the Hyperliquid account to show at least 12 USDC available before
launch; failures route back to the matching guided-setup step.
2026-07-05 19:13:50 +09:00
tinkle-community
49613830ce feat: client-side simulation layer for terminal dashboard panels
Adds a local synthetic data engine that can drive all dashboard panels
(candles, order book, liq ladder, stats) without a backend connection,
plus de-synced beam animation phases in the orchestration topology.
2026-07-05 19:13:44 +09:00
tinkle-community
ea74f91de2 Update README demo video 2026-06-30 23:05:00 +08:00
tinkle-community
a6884998b3 docs: embed inline quick-demo video in READMEs
Replace the cover-image link (Google Drive) with the GitHub-hosted demo
video so it plays inline, across all language READMEs. Add an
"illustrative simulated preview — not real trading performance" note
next to it in each locale.
2026-06-30 22:35:09 +08:00
tinkle-community
277b5ad9c6 refactor: drop equity curve + AI rationale panels, rebalance dashboard footer
- Remove the equity curve (only ever 2 points → a meaningless straight line);
  also drop the now-unused equity-history SWR poll and path calc
- Remove the Latest Decision / AI rationale panel (redundant with Execution Log)
- Rebalance the footer: Market Net Inflow + By Symbol now form one two-column
  row (1.5fr / 1fr) instead of full-width inflow + a lonely by-symbol column
2026-06-30 16:13:59 +08:00
tinkle-community
110bf52908 feat: cream terminal redesign, English-only UI, autopilot launch fixes
- Redesign dashboard into a cream-paper + vermilion IBM Plex Mono terminal
  (live L2 order book, cost/liq map, WS K-line, signal matrix, orchestration
  topology, risk radar, execution log, current positions, equity curve)
- Convert all user-facing UI and backend strings/prompts from Chinese to
  English (multi-language retained, default English)
- Add /api/statistics/full endpoint + full-stats frontend wiring
- Fix Autopilot launch: reuse the existing trader instead of creating
  duplicates (eliminates repeat ~35s create cost and stale-trader 404s);
  launch sends 5m scan interval
- Fix unreadable toasts: cream theme with high-contrast text + per-type accent
- Silence background dashboard polls (getTraderConfig) to stop error-toast spam
2026-06-30 16:03:52 +08:00
tinkle-community
eba28bcf0e Sync manual closes into position history 2026-06-28 12:17:45 +08:00
tinkle-community
c4e79d9579 Fix Claw402 autopilot launch and accounting 2026-06-28 11:36:56 +08:00
tinkle-community
a4983d2cb0 fix: enable full-size Claw402 autopilot trading 2026-06-27 11:31:19 +08:00
tinkle-community
24f6421a73 feat: simplify Claw402 autopilot trading flow 2026-06-27 00:37:59 +08:00
tinkle-community
961e016d33 fix: harden Hyperliquid agent renewal 2026-06-21 19:23:20 +08:00
tinkle-community
b95da3ed42 chore: remove stray screenshot from repo, ignore img.png 2026-06-11 22:18:27 +08:00
tinkle-community
a47811f5ab fix(ai500): flat API response for the panel, list-format chat replies
- /api/ai500 returned a success/data envelope but the web httpClient
  wraps the raw body as data, so the panel read coins as undefined and
  showed the empty state; return a flat {coins,count} body like
  /api/symbols
- the agent rendered AI500 rankings as a markdown table that the chat
  UI flattens into an unreadable line: tool note + system prompt now
  mandate numbered lists (one coin per line) and ban tables outright
2026-06-11 22:18:05 +08:00
tinkle-community
2c6e2827e8 feat(agent): surface the AI500 index board in chat, tools, and sidebar
- provider/nofxos: GetAI500ListCached — 5min TTL cache with stale
  fallback on upstream failure; ResolveClient routes through the claw402
  gateway when a wallet key is configured (user's claw402 model key ->
  CLAW402_WALLET_KEY env -> direct nofxos)
- new GET /api/ai500 endpoint serving the score-sorted board
- new get_ai500_list agent tool + prompt rule: when the user wants coin
  picks or creates a strategy without naming coins, consult AI500's
  high-scoring entries by default
- web: AI500 sidebar panel (rank, score badge, gain since entry,
  5min auto-refresh); clicking an entry asks the agent to analyze it
2026-06-11 22:11:03 +08:00
tinkle-community
953240565f fix(trader): stop order-sync goroutine leak and rate-limit hammering
Every StartOrderSync spawned a ticker goroutine that ran forever — it
survived trader stop AND deletion, so each quick-created trader left a
permanent 30s Hyperliquid poll behind. Stacked leaks turned into an
~8s effective hammer that tripped Hyperliquid's 429 rate limit, which
then broke the symbol board, trader creation, and order sync itself.

- new trader/syncloop package: shared stoppable sync loop with
  exponential failure backoff (30s base, 5min cap)
- all 9 exchanges' StartOrderSync now take the trader's stop channel
  and stop when the trader stops (close broadcast from AutoTrader.Stop)
- provider/hyperliquid: GetPerpDexCoins now serves a 5min TTL cache and
  falls back to the stale board when the upstream returns 429, so the
  symbol panel keeps working through rate limiting
2026-06-11 21:45:31 +08:00
tinkle-community
133ef51de8 fix(hyperliquid): stop SDK init panic from crashing trader creation with 500
go-hyperliquid's NewExchange auto-fetches meta/spotMeta/perpDexs and
panics if any of those API calls fail (NewInfo: panic(err)). The quick
trade flow constructs a probe trader inside POST /api/traders, so any
transient Hyperliquid API hiccup crashed the request with a recovered
panic and a bare 500. Wrap the constructor in initExchangeClient, which
converts the panic into an error that surfaces through the existing
exchange-probe validation path as an honest, retryable message.
2026-06-11 21:33:55 +08:00
tinkle-community
bebe51bf89 fix(deps): resolve remaining 2 Dependabot advisories
- gnark-crypto 0.19.0 -> 0.19.2 (GHSA-fj2x-735w-74vq, HIGH: unchecked
  memory allocation during vector deserialization; indirect, uncalled)
- pgx/v5 5.9.0 -> 5.9.2 (GHSA-j88v-2chj-qfwx, LOW: SQL injection via
  dollar-quote placeholder confusion; indirect via gorm postgres driver)
2026-06-11 01:56:11 +08:00
tinkle-community
3a048876bd fix(agent): keep suspended-task snapshots on the legacy stack
suspendActiveContexts clears all active contexts after parking a task on
the snapshot stack, so the active-context guard alone let the agentic
loop hijack resume requests and strand suspended tasks. Check the
snapshot stack in shouldUseAgenticTurn.
2026-06-11 01:12:36 +08:00
tinkle-community
4f3869c81c feat(agent): defaults-first prompts and one-shot creation flows
- system prompt (zh+en): finish-the-work-then-report rule — chain tools in
  the current turn, never promise background work; prefill industry-standard
  defaults instead of interrogating field by field, batch any unavoidable
  questions into one
- manage_strategy tool description: create only needs name, omitted config
  merges from the default template, one-shot summary + confirmed=true flow
- manage_trader tool description: resolve exchange/model/strategy bindings
  via list tools instead of asking the user for IDs
2026-06-11 01:06:11 +08:00
tinkle-community
f3c33b55d7 feat(agent): make the agentic loop the primary brain path
thinkAndAct/thinkAndActStream now try the native function-calling loop
first for fresh conversations; in-flight legacy flows (skill sessions,
workflows, execution states, pending proposals) stay on the legacy stack
until they finish. NOFX_AGENT_V2=off restores the old routing entirely.
2026-06-11 01:03:08 +08:00
tinkle-community
785922697b feat(agent): add native function-calling agentic loop as the new brain core
One standard tool-use loop replaces the need for layered JSON routing:
the LLM sees all 22 tools plus real multi-turn history, every tool result
(including errors) returns to the loop as an observation, and the final
user-facing reply is always LLM-written. Interruptions report exactly
which tools already executed so side effects are never silently lost or
repeated by fallback paths. Gated by NOFX_AGENT_V2 (default on).
2026-06-11 01:00:41 +08:00
tinkle-community
332ddf61ef fix(trader): stop over-attributing entry fees on partial position closes
The FIFO matcher reduced an open trade's remaining quantity but not its
remaining fee, so each subsequent partial close re-attributed entry fee
that earlier closes had already counted (e.g. open 2.0 with fee 0.4,
two 1.0 closes attributed 0.6 total). Deduct the consumed fee portion
alongside the quantity so attributed fees sum to the fee actually paid.
2026-06-11 00:45:06 +08:00
tinkle-community
41c2625bb2 test(manager,market,trader): cover previously untested core paths
- manager: 15 tests incl. concurrent map access under -race (was 0 tests)
- market: timeframe normalization regression tests
- trader: FIFO position rebuild tests (partial closes, hedge/one-way mode,
  PnL-fallback entry price, invalid input)
2026-06-11 00:37:45 +08:00
tinkle-community
c0d8a9a375 refactor(trader): name trading-logic magic numbers
- marginOverheadFactor/takerFeeRate/positionSizeSafetyFactor in sizing math
- aggressiveBuyPriceFactor/aggressiveSellPriceFactor in hyperliquid and aster
  simulated market orders
- dustQuantityEpsilon in FIFO position rebuild
2026-06-11 00:33:11 +08:00
tinkle-community
9ea9bd705f fix(trader): harden API calls with timeouts, strict balance parsing, error context
- binance/bybit/gate: SDK default http.DefaultClient has no timeout; use a
  dedicated 30s-timeout client so a hung connection cannot stall the loop
- bybit: stop mutating http.DefaultClient.Transport, which leaked the
  referer header into every other HTTP request in the process
- add types.ParseFloatField: empty exchange fields stay zero, but malformed
  numeric values now surface as errors instead of silently becoming zero
  balances (applied to GetBalance across 8 exchanges)
- wrap order/market-data errors in auto_trader_orders and okx cancel paths
  with symbol context; log per-order cancel failures in okx CancelAllOrders
2026-06-11 00:30:34 +08:00
tinkle-community
094ab45476 fix(trader): stop swallowing critical errors in order paths
- check CancelAllOrders errors in okx/kucoin/bitget/gate open/close paths
  (aligns with existing binance/hyperliquid/aster/bybit pattern)
- log saveDecision failures in auto_trader_loop instead of discarding
- remove dead MustNormalizeTimeframe that panicked in market package
- web: npm audit fix resolves react-router HIGH CVEs (GHSA-49rj-9fvp-4h2h,
  GHSA-2j2x-hqr9-3h42, GHSA-8x6r-g9mw-2r78, GHSA-rxv8-25v2-qmq8)
2026-06-11 00:12:58 +08:00
tinkle-community
220cb7428b fix(deps): resolve 3 critical Dependabot advisories
- go: bump github.com/jackc/pgx/v5 v5.6.0 -> v5.9.0 (CVE-2026-33815 /
  CVE-2026-33816, memory-safety in the Postgres driver). govulncheck reports
  0 affecting vulnerabilities after the bump.
- ci: pin aquasecurity/trivy-action to commit SHA ed142fd (v0.36.0) instead of
  the mutable @0.28.0 tag (GHSA-69fq-xp46-6x23, brief upstream supply-chain
  compromise). Dependabot now updates the SHA.
- web: bump vitest ^4.0.16 -> ^4.1.0 (lockfile now 4.1.8) for
  GHSA-5xrq-8626-4rwp (Vitest UI server arbitrary file read/exec; dev-only).
2026-06-05 22:19:27 +08:00
tinkle-community
1aea7abc38 fix(security): remove decrypt oracle, redact secret logs, harden auth, bump Go
Address multiple vulnerabilities found during security review:

- Remove unauthenticated POST /api/crypto/decrypt decryption oracle (route,
  handler, dead frontend helper) + regression test. Transport encryption is
  one-directional; the server never needs to decrypt arbitrary client payloads.
- Redact secrets in config-update logs: handler_ai_model/handler_exchange logged
  %+v of decrypted requests, leaking API keys / secret keys / passphrases /
  private keys. Use named types shared with the log sanitizer so the masking
  can never drift again; extend masking to passphrase + lighter_api_key_private_key.
- crypto: require a valid timestamp in DecryptPayload (a missing ts previously
  skipped replay protection entirely).
- crypto: EncryptedString.Value() now fails closed instead of silently
  persisting plaintext secrets when encryption errors.
- auth: per-IP token-bucket rate limiting on /login and /register against online
  brute-force; raise registration password minimum 6 -> 8; add dummy bcrypt
  compare on unknown-email login to close the user-enumeration timing channel.
- IDOR: getTraderFromQuery no longer falls back to the global in-memory trader
  map; trader access is strictly scoped to the authenticated caller.
- Bump Go 1.25.10 -> 1.25.11 to resolve reachable net/textproto and crypto/x509
  stdlib advisories (govulncheck now reports 0 affecting vulnerabilities).
2026-06-05 22:08:26 +08:00
tinkle-community
577a0918c3 fix(security): move account recovery to local CLI, remove unauthenticated reset endpoints
Unauthenticated POST /api/reset-password and /api/reset-account were a
remotely exploitable auth-bypass on public-facing deployments. The confirm
phrase was embedded in the frontend and echoed back by the API, so it was
friction, not authentication: anyone who knew the account email could reset
the password, log in, and obtain a valid JWT.

Recovery now runs as local CLI commands that operate directly on the database
without starting the HTTP server:

  nofx reset-password --email you@example.com
  nofx reset-account

These require shell/file access to the host, which a remote attacker does not
have, so recovery stays safe even when NOFX is exposed to the public internet.

- cli.go: new reset-password / reset-account subcommands (hidden password
  input on a TTY, --password/stdin for scripting, min 8 chars)
- main.go: dispatch subcommands before the server starts (backward compatible
  with the legacy `nofx <dbpath>` arg)
- api: remove public /reset-password and /reset-account routes, their handlers,
  and the public confirm-phrase constants
- web: replace the self-service reset form with CLI instructions; drop the
  AuthContext resetPassword call and the LoginPage reset-account call (en/zh/id)
- telegram: refresh the bot allowlist comment
2026-06-05 10:49:21 +08:00
tinkle-community
2d32a8f6c9 chore(hyperliquid): refresh shared wallet-connect constants
The Hyperliquid wallet-connect flow signs configuration values that
must match what the server expects and what the order placement layer
sends on-chain. The same constants live in four call sites:

  - trader/hyperliquid/trader.go     (used at order placement)
  - api/handler_hyperliquid_wallet.go (returned by the connect endpoint
                                       and validated on submit)
  - web/src/components/common/HyperliquidWalletConnect.tsx
                                       (signed by the user during connect)
  - trader/hyperliquid/builder_fee_test.go
                                       (pins the trader-side value)

Refresh all four together so the surfaces stay in lockstep.
2026-06-02 12:04:28 +08:00
tinkle-community
3c061aee94 fix(security): tighten strategy-market iframe permissions
Two issues in the prior commit that the embedded vergex.trade explore
iframe did not actually need:

  1. `allow=clipboard-write` granted the iframe silent write access to
     the user's clipboard via the Clipboard API. A compromised or
     compromised-by-injection vergex page could overwrite copied
     content — classic clipboard-hijack pattern (e.g. swap a copied
     wallet address right before the user pastes it into a send form).
     The explore view does not need this capability; drop it. Matches
     the existing DataPage.tsx iframe pattern.

  2. No `sandbox` attribute, so the iframe ran with full implicit
     permissions: arbitrary scripts, form submission, top-level
     navigation, modals, pointer lock, etc. Add an explicit sandbox
     whitelist that grants only what the explore view actually uses:

       allow-scripts allow-same-origin allow-forms
       allow-popups allow-popups-to-escape-sandbox

     Notably withheld:
       - allow-top-navigation: the iframe cannot redirect the NOFX
         shell to an arbitrary URL.
       - allow-modals / allow-pointer-lock / allow-orientation-lock:
         not used by the explore page.
       - allow-storage-access-by-user-activation: keeps third-party
         storage access prompts off the embedded surface.

Verified: explore page renders identically; no sandbox-related
violations in the console (residual errors are vergex's own internal
CSP rejecting analytics + asset fetches, unrelated to our embedding).
2026-06-02 01:56:32 +08:00
tinkle-community
30c6abca74 feat(web): inline-embed vergex.trade/explore in the strategy market
vergex.trade now lists the NOFX origins in the enforced CSP
`frame-ancestors` directive for the /explore path:

  frame-ancestors 'self' https://nofxos.ai https://www.nofxos.ai
                  http://127.0.0.1:3000 http://localhost:3000

so cross-origin embedding from any NOFX deployment works. The
X-Frame-Options header is still SAMEORIGIN, but modern browsers honor
the CSP `frame-ancestors` directive when both are present (per CSP
Level 2), and the embed verifies cleanly under Chromium.

Replaces the prior fallback "open in new tab" CTA card with the same
iframe pattern DataPage.tsx already uses for vergex.trade/trending —
single iframe filling the AppChrome content area, full-screen and
clipboard permissions enabled, strict-origin referrer.
2026-06-02 01:49:22 +08:00
tinkle-community
129952859e chore(gitignore): exclude local agent/skill scaffolding
.agents/ holds editor-side agent definitions and skills-lock.json is a
local skill manifest — neither belongs in the repo. Also adds a trailing
newline so the file ends cleanly.
2026-05-31 23:59:04 +08:00
tinkle-community
7f0a9f0749 fix(hyperliquid): bump go-hyperliquid v0.26 -> v0.36 to dodge spot-meta panic
go-hyperliquid v0.26.0 crashed at startup with

    panic: runtime error: index out of range [479] with length 464
    github.com/sonirico/go-hyperliquid.NewInfo (info.go:75)
    NewExchange -> NewHyperliquidTrader -> AutoTrader.NewAutoTrader
    -> TraderManager.LoadTradersFromStore -> main.main

The library's NewInfo built the spot-asset map by indexing
`spotMeta.Tokens[spotInfo.Tokens[0]]` directly, but Hyperliquid recently
added spot tokens whose Tokens[0] value (a logical token *index*, not an
array position) was larger than the Tokens slice length. With every
restart the backend panicked before the API server bound, so the
frontend's `/api/*` proxy got connection refused on every poll and the
dashboard rendered "全是 error" toasts.

v0.36 fixes the panic by building `tokensByIndex map[int]SpotTokenInfo`
and looking up by logical index instead of position. Adopting v0.36
required two small adaptations to our wrapper:

  - trader/hyperliquid/trader.go: NewExchange grew an extra `perpDexs
    *MixedArray` argument. Passing `nil` keeps the existing
    "auto-fetch on first use" behavior.
  - trader/hyperliquid/trader_sync.go: `Info.NameToAsset(coin) int` was
    renamed to `Info.CoinToAsset(coin) (int, bool)` with an `ok` flag.
    refreshMetaIfNeeded now treats `!ok || assetID == 0` as "needs
    refresh" (the same semantic as the old `assetID == 0`).

Verified: backend rebuilds cleanly, container is healthy, all
Hyperliquid traders load, AI cycles execute, and Hyperliquid order
sync receives the full historical trade window.
2026-05-30 01:48:11 +08:00
tinkle-community
b15c2da3a9 feat(strategy): English-only XYZ stock prompt + flat-account aggression + tier promote
The strategy prompt the LLM saw for a Chinese-language single-symbol US
stock trader was an incoherent zh/en patchwork — schema in Chinese,
role definition in English, hard constraints in English, custom prompt
back in Chinese — with crypto-flavored BTC/ETH vs Altcoin labelling that
made no sense for ARM-USDC. The LLM responded by being conservative and
boring. When it finally tried to open, the validator rejected the order
because the validator classified the stock as an altcoin (1x equity cap
= 112 USDT max) while the prompt said 5x cap (= 559 USDT).

- kernel/engine_prompt.go (BuildSystemPrompt): all eight prompt sections
  now respect e.GetLanguage() consistently. For single-symbol
  Hyperliquid XYZ assets (US stocks, commodities, forex) we additionally
  force the language to English regardless of the strategy's stored
  language — US-equity reasoning lands better in English and prevents
  the language-mix incoherence. The Hard Constraints section drops the
  BTC/ETH vs Altcoin two-tier split when the strategy trades a single
  instrument and shows one Position Value Limit line tagged with the
  actual symbol. The JSON example uses that symbol instead of the
  legacy BTCUSDT/ETHUSDT. The legacy stored custom_prompt (which was
  Chinese for stock quick-creates) is replaced for XYZ assets by
  buildXYZStockCustomPrompt — a built-in English long-only stock
  briefing that includes a Flat-Account Rule: when Current Positions
  is None, the agent MUST open a long this cycle (size 40-60% probing
  if technicals are mixed, 80-100% on a confirmed breakout). This is
  the "be in the market, not on the sidelines" stance the quick-trade
  flow needed; wait/hold are reserved for when a position already
  exists.

- kernel/engine_position.go + trader/auto_trader_risk.go + agent/trade.go:
  Hyperliquid XYZ assets now use the BTC/ETH higher tier rather than
  the altcoin tier in all three position-value enforcement points. A
  shared isMajorAsset / isMajorTradeSymbol helper treats BTC/ETH crypto
  perps AND any IsXyzDexAsset symbol as the higher tier. With 5x
  equity cap, the AI's confident-open decisions on US stocks now pass
  validation instead of erroring out with "altcoin single coin position
  value cannot exceed 112 USDT".

Net result: on a flat US-stock single-symbol trader, the agent opens
a sized position with stop-loss and take-profit on the very first
flat cycle, manages it (trail / partial / cut), and reports honestly
to the user. The "agent does nothing" complaint is closed.
2026-05-29 22:15:35 +08:00
tinkle-community
d008ccc6ab fix(market): route Hyperliquid USDC perps correctly + symbol fuzzy match
A single-symbol QNT-USDC trader produced 0 candidate coins, 500 errors
from Hyperliquid, and "🚫 Dropped AI decision" warnings — the agent had
no market data to reason about, so it sat in `wait` forever. Three
chained bugs:

1. provider/hyperliquid/kline.go (IsXYZAsset / FormatCoinForAPI):
   asset detection required the base symbol to appear in the hardcoded
   StockPerpsSymbols / XYZOtherSymbols / display-alias lists. QNT, ARM,
   and every other newly-listed Hyperliquid USDC perp wasn't in the
   list, so the code routed them to the crypto path (CoinAnk) which
   doesn't have them. Now the `-USDC` suffix and `xyz:` prefix are
   trusted as definitive Hyperliquid signals — these tokens are
   Hyperliquid-specific and new listings don't require a code change.
   The hardcoded lists are kept as fallbacks for bare base symbols.

2. market/data_klines.go (getKlinesFromHyperliquid): the function
   stripped the `xyz:` prefix before calling GetCandles, defeating
   GetCandles's own FormatCoinForAPI logic. With the hardcoded list
   missing the new ticker, FormatCoinForAPI couldn't re-add the prefix
   and the request hit Hyperliquid's crypto perp endpoint — which
   returns 500 for stock-only tickers. Pass the symbol through as-is.

3. trader/auto_trader_loop.go (filterDecisionsToStrategyUniverse): the
   AI sometimes echoes a candidate as "QNTUSDC" / "QNT-USDC" / "QNTUSDT"
   / bare "QNT" instead of the canonical "xyz:QNT" we supplied. Strict
   exact-match was dropping all of them. Added a base-level key
   (strips xyz:, -USDC, -USDT, USDC, USDT, USD; normalizes display
   aliases like ROBINHOOD → HOOD) and rewrites the matched decision's
   symbol to the canonical form so the order pipeline downstream sees
   the format it expects.

After this, a single-symbol stock trader fetches real K-line data from
Hyperliquid, the AI sees real candidates, and decisions get executed
on-chain instead of silently filtered.
2026-05-29 22:14:41 +08:00
tinkle-community
e4adafa364 feat(web): quick-trade button actually trades - auto-start + honest status
The lightning button on the symbol panel was the single biggest
"agent does nothing" complaint: it created a trader and a strategy via
direct REST calls, then handed the user a hardcoded reply that read
"我没有自动启动实盘交易。请到 Traders 面板确认风控后手动 Start" —
i.e. the chat bot openly admitted it bypassed the agent and refused to
do the work the user had clearly asked for.

- web/src/lib/hyperliquidQuickTrade.ts: after createStrategy +
  createTrader (or finding an existing trader), call POST
  /api/traders/:id/start immediately. Report `started`, `reusedTrader`,
  and an optional `startError` so the chat reply can be honest about
  what happened — created vs reused, running vs failed, and why.

- web/src/pages/AgentChatPage.tsx: replace the canned "please start
  manually" reply with one that reflects reality. Success path shows
  the symbol, strategy, 5-min scan interval, and how to halt it via
  chat. Failure path surfaces the actual start error and tells the
  user the trader exists but is not running.

- web/src/lib/hyperliquidQuickTrade.ts: per-symbol prompt now routes
  on category. Stocks (category="stock") get a long-only, momentum-
  seeking prompt — break of high, volume spike, support reclaim, sector
  catalyst — because shorting individual US equities through the agent
  is rarely what the user wants. Crypto stays bidirectional but
  disciplined. The trader-level custom_prompt is rewritten in the same
  style and explicitly forbids rotating to other symbols.
2026-05-29 22:13:51 +08:00
tinkle-community
1851508353 feat(agent): make the assistant agentic - visible tools, LLM voice, full toolset
The agent felt like an artificial idiot because the LLM almost never spoke
for itself: 14+ Go paths injected fmt.Sprintf canned replies, the frontend
filtered out tool-progress events so users saw three dots for 10-20s, the
main prompt told the LLM "be a trading partner" AND "answer only what's
asked", and the planner sliced the toolset by inferred domain so a "BTC
dropped, how much am I losing?" question couldn't see positions and market
at the same time.

- agent/central_brain.go: shouldTrustDeterministicSkillReply now always
  returns false. Successful mutations (trader/strategy/model/exchange
  create/update/start/stop/delete) flow through reviewTaskCompletion so the
  LLM sees the real outcome JSON and writes the user-facing prose. The
  trade-confirmation regex path (handleTradeConfirmation) was already
  outside this code path and is unaffected.

- agent/agent.go: rewrite the Behavior section of the main system prompt.
  Replace the contradictory "answer only what's asked / don't upsell" with
  "lead with the direct answer, then optionally one relevant follow-up
  only when (a) open risk, (b) missing config, or (c) the next step is
  obvious — e.g. created, want me to start it?". Explicitly authorize
  chaining ("if the user says create and start, do both this turn") and
  ban "please wait / I'll get back to you" language because there is no
  background job to come back from.

- agent/tools.go: plannerToolsForText always returns the full 22-tool set
  (new __all__ domain). The old per-domain trimming hid manage_trader from
  market questions and execute_trade from anything that didn't look like
  an explicit trade — cross-domain reasoning was structurally blocked. The
  compact-vs-full strategy schema switch is preserved so mutation intents
  still see the full config schema.

- web/src/components/agent/{AgentStepPanel,ChatMessages}.tsx: stop
  filtering tool: steps. Map raw tool names to friendly labels with emoji
  ("get_positions" → "📊 检查持仓") in zh/en/id. Users now see what the
  agent is doing in real time instead of silence. central_brain routing
  chatter still gets dropped.

- agent/planner_tools_test.go: tests updated to assert the new
  full-toolset behavior and the compact-vs-full strategy schema switch.
2026-05-29 22:13:05 +08:00
tinkle-community
fcb73cc195 fix(deps): clear all Go vulnerabilities (govulncheck 20 -> 0)
govulncheck @ vuln.go.dev 2026-05-26 db reported 20 advisories that the
code actually calls (13 stdlib, 7 third-party). After this commit:
0 vulnerabilities affecting the code.

Third-party module bumps (within existing import sites, no API changes):
- github.com/ethereum/go-ethereum v1.16.7 -> v1.17.3
  (GO-2026-4314 / -4315 / -4507 / -4508)
- github.com/quic-go/quic-go v0.54.0 -> v0.59.1
  (GO-2025-4233 HTTP/3 QPACK header expansion DoS)
- github.com/golang-jwt/jwt/v5 v5.2.0 -> v5.3.1
  (GO-2025-3553 excessive memory allocation in jwt.Parser)
- golang.org/x/net v0.43.0 -> v0.55.0 (GO-2026-4918)

go directive: 1.25.3 -> 1.25.10 to pull stdlib patches for
net/mail, net, net/http, net/url, crypto/x509, crypto/tls, html/template
(GO-2025-4155 / -4175, GO-2026-4337 / -4340 / -4341 / -4601 / -4865 /
-4870 / -4918 / -4946 / -4947 / -4971 / -4977 / -4986). Toolchain
auto-downloads via GOTOOLCHAIN=auto; docker base golang:1.25-alpine is
already 1.25.10 so production builds are unaffected.

Verified: go build ./... clean, go vet clean, go test ./auth ./mcp
./api ./store ./trader/... all green, govulncheck reports 0.
2026-05-29 16:27:19 +08:00
tinkle-community
b9ae99da7e fix(deps): bump web transitive deps via npm audit fix
Resolves 12 local advisories (3 high, 3 moderate, 6 low) with zero
direct-dep version changes — all within existing semver ranges:

- axios 1.13.6 -> 1.16.1 (SSRF via NO_PROXY bypass, prototype pollution
  via validateStatus/parseReviver, CRLF injection in multipart bodies,
  null byte injection in URLSearchParams)
- vite 6.4.1 -> 6.4.2 (high)
- lodash 4.17.23 -> 4.18.1 (high)
- postcss 8.5.6 -> 8.5.15 (moderate)
- plus the rest of the transitive graph

package.json is unchanged. tsc passes, frontend container rebuilds
cleanly, login page renders without console errors. Verified via
docker compose up -d --build nofx-frontend.
2026-05-29 16:18:16 +08:00
tinkle-community
75832f9eb2 feat(web): redesign login page and proxy strategy market to vergex.trade
- LoginPage: two-column desktop layout with brand panel (status pill,
  gradient headline, stats strip) and form panel; single-column mobile
  layout with centered brand mark. Self-contained grid centering so
  layout no longer depends on parent flex behavior. Drop the dead
  OnboardingModeSelector (it belongs to SetupPage, not login) and add
  loader spinner, animated submit arrow, and clearer error banner.
- StrategyMarketPage: replace the 560-line bespoke marketplace with a
  branded handoff to vergex.trade/explore. Direct iframe embedding is
  currently blocked by vergex's X-Frame-Options: SAMEORIGIN and
  frame-ancestors 'self', and there is no way to reliably detect the
  block from JavaScript (load event fires for the browser error page,
  contentWindow.location throws SecurityError in both success and
  failure). The component now renders a centered card with the
  POWERED BY VERGEX.TRADE pill, headline, description, gold CTA, and
  a stats row, with all three supported languages.
- .gitignore: exclude .gstack/ (local security audit reports).
2026-05-29 16:14:46 +08:00
tinkle-community
99361cb085 fix(security): harden auth flows and lock down telegram bot tool
- config: require JWT_SECRET >=32 bytes and reject the historical
  default fallback; MustInit aborts startup under an insecure config
- api: CORS now uses CORS_ALLOWED_ORIGINS allowlist with safe
  localhost defaults instead of returning Access-Control-Allow-Origin: *
- api: /api/reset-password and /api/reset-account stay public so
  recovery still works, but require an explicit confirm phrase in the
  body to block accidental and drive-by triggers
- api: drop adoptOrphanRecords so wiping the account no longer hands
  the next registrant the previous owner's wallet keys and exchange
  API credentials
- api: getTraderFromQuery now does a soft ownership check; equity-history
  is restricted to traders with show_in_competition=true and
  GetOrderFills joins on trader_id
- telegram: bot api_request tool uses a default-deny method+path
  allowlist so prompt injection cannot reach password, exchange key,
  AI provider or wallet endpoints
- ci: drop @master / @main on trivy-action and trufflehog; pin to
  released versions with a TODO to move to SHA + Dependabot
- web: reset flows send the required confirm phrase; "Forgot account"
  copy (en/zh/id) warns that wallet and exchange keys will be lost
- docker-compose: keep ./.env mount for onboarding wallet persistence
  with an inline note on the tradeoff, drop the host-exposed pprof port
2026-05-29 07:51:26 +08:00
tinkle-community
70db3f5ba3 docs(readme): add vergex.trade backing and sync localized READMEs
- Add 'Backed by vergex.trade' banner to English and all localized READMEs
- Sync 6 localized READMEs (zh-CN, ja, ko, ru, uk, vi) to match English structure
- Add missing sections: Screenshots, Deploy to server, Architecture, Sponsors
- Remove orphaned root-level README.ja.md (now lives in docs/i18n/ja/)
2026-05-25 16:05:07 +08:00
tinklefund
f2eeea9659 docs(i18n): align localized READMEs with market positioning
- Sync localized README positioning with global market terminal messaging

- Move exchange registration and fee discount links forward

- Replace legacy AI model tables with automatic Claw402 access

- Update VergeX links across translated docs
2026-05-25 02:04:03 +08:00
tinklefund
eb73c8bdfa docs(readme): refine market positioning and Claw402 access
- Lead with global market coverage and exchange registration links

- Position Claw402 as automatic pay-as-you-go model access

- Update VergeX links and remove stale API docs reference
2026-05-25 01:56:51 +08:00
tinklefund
dea00b418c docs(readme): emphasize multi-market AI trading terminal
- Position NOFX around US stocks, commodities, forex, and crypto

- Update the README hero, feature summary, markets section, and architecture label

- Keep the copy affirmative and avoid defensive crypto-only comparisons
2026-05-25 01:35:07 +08:00
tinklefund
3b2e7027db feat(web): refresh Hyperliquid-focused product UI
- Update landing, chart, settings, and data page copy for stock trading

- Adjust branding and translations around Hyperliquid positioning

- Extend frontend config types for the updated exchange settings
2026-05-25 01:25:23 +08:00
tinklefund
f4ee723aa2 feat(agent): surface Hyperliquid stock trading context
- Add stock symbol panel and agent chat page wiring

- Update onboarding and tool visibility for focused trader flows

- Tighten related tests around configuration and trader scope
2026-05-25 01:25:10 +08:00
tinklefund
5bdffee3b0 feat(strategy): support Hyperliquid stock strategy editing
- Extend strategy storage and engine analysis for Hyperliquid defaults

- Rework coin source and indicator editors for the stock strategy flow

- Update Strategy Studio translations and page wiring
2026-05-25 01:25:05 +08:00
tinklefund
c7c003cc3c feat(trader): wire Hyperliquid wallet and quick trade flow
- Add wallet API endpoints and exchange storage fields for Hyperliquid

- Normalize quick trade order paths, symbols, and builder fee coverage

- Add frontend wallet connect and quick trade helpers
2026-05-25 01:24:58 +08:00
tinklefund
f37fc9f887 feat(hyperliquid): add stock symbol market data support
- Add Hyperliquid/XYZ symbol normalization tests and backend coverage

- Extend kline and market data lookup paths for US stock symbols

- Wire frontend data API types for stock-oriented market requests
2026-05-25 01:24:49 +08:00
tinklefund
908fc09aca feat(strategy): replace default presets with Hyperliquid US stock strategies
Remove the old generic risk-profile defaults from the user strategy bootstrap path and replace them with concrete Hyperliquid USDC equity presets that can be selected directly when creating an AI trader.

Add three ready-to-run strategy presets: a volume-ranked US stock trend preset, a fixed mega-cap preset covering AAPL-USDC/MSFT-USDC/GOOGL-USDC/AMZN-USDC/META-USDC, and a gainers-ranked US stock breakout preset.

Normalize the presets to use Hyperliquid-native stock discovery instead of AI500/OI crypto-style sources, with conservative defaults for max positions, leverage, margin usage, confidence, risk-reward, and multi-timeframe indicators.

Make default strategy synchronization idempotent for existing users: remove obsolete unused legacy preset rows, backfill the new US stock presets, and avoid overriding an already active custom strategy.

Update the trader creation modal preview labels so Hyperliquid stock ranking and fixed US stock sources are described clearly when users select a strategy.

Add API tests covering the new preset set, legacy preset cleanup, idempotent sync behavior, and preservation of an existing active custom strategy.

Verified with: go test ./api ./store; npm run build; docker compose up -d --build nofx nofx-frontend; backend /api/health; frontend HTTP 200; compose health checks.
2026-05-25 01:20:05 +08:00
shinchan-zhai
ab5873e2de refactor(agent): improve legacy loop comment and extract domain variable
Clarify the rationale for not injecting conversation history in the
legacy loop comment, and extract plannerToolDomainForText result into
a named variable for readability.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-12 00:16:20 +08:00
shinchan-zhai
d80bb31c0a fix(web): fix UI bugs and unify design tokens
- Add missing .nofx-glass CSS class (used in 20+ places but undefined)
- Fix Input component referencing undefined --brand-black/--brand-light-gray
- Unify background colors to #0B0E11 (was 3 different near-blacks)
- Switch body font from IBM Plex Mono to Inter for readability
- Improve chat bubble contrast (bg 0.03→0.05, border 0.05→0.08)
- Brighten timestamp (#2c2c42→#5a5a72) and disclaimer (#1e1e32→#4a4a62)
- Unify ::selection color to gold (was orange)
- Remove global button:hover translateY that conflicted with active:scale

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 23:51:27 +08:00
shinchan-zhai
e2ccc6b911 fix(agent): eliminate cross-turn topic pollution in legacy loop
Remove conversation history injection from thinkAndActLegacyWithStore.
Previously, the legacy loop appended all prior Q&A turns, causing the
LLM to re-answer topics from earlier conversations (e.g. strategy data
leaking into a wallet balance question). Each legacy-loop call is now
treated as a standalone request with domain-filtered tools.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 23:51:18 +08:00
shinchan-zhai
bf289e8eb3 fix(agent): reduce verbose responses — focus answers on user's question only
Root cause: when planner fails (402 payment), legacy loop dumps all system
context to LLM which outputs everything. Also final response prompt was too
weak — LLM treated all observations as required output.

Changes:
- Strengthen system prompt: "answer ONLY what user asked", no tables/tutorials
  unless requested, no self-intro repeats, no "next step" suggestions
- Add compact observation summary for final response (step summaries only,
  no raw JSON blobs)
- Domain-filtered tool selection in legacy loop to prevent over-fetching
- Fix domain routing: "钱包/wallet" → account domain (not model), with
  exchange configs included for wallet context
- Widen wallet fast-path: no longer requires "claw402" keyword
- Anti-repetition instructions in planner step selector

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 21:12:48 +08:00
shinchan-zhai
9f25bf49bf fix(agent): use provider registry for claw402, echo reasoning_content for thinking models, add Beta badge
- Agent now uses mcp.NewAIClientByProvider() for claw402 provider, ensuring
  x402 payment signing works correctly instead of generic HTTP client
- Added ReasoningContent field to Message/LLMResponse structs and wired
  serialization/parsing so DeepSeek thinking models work in multi-turn
- Added Beta badge to Agent nav tab in HeaderBar

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 20:22:32 +08:00
shinchan-zhai
b8cde34e67 feat(agent): add NOFXi agent chat workflow (#1495)
- Add NOFXi agent backend: central brain, planner runtime, skill routing,
  memory/state handling, config validation, and action execution
- Add agent chat page with SSE streaming, step/status panels, and
  user preferences
- Extend trader/model/exchange/strategy APIs and store for agent-driven
  configuration
- Add stopCh guard in async maintenance goroutine to prevent leak on Stop()
- Add timeout context for trader diagnosis LLM calls
- Add TargetRef nil guards in all execute*Action handlers
- Add ensureHistory() for nil-safe history access

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 16:54:53 +08:00
shinchan-zhai
32e8a03a85 merge: resolve conflicts from origin/dev into PR #1495
- Use PR branch (dev-nofxi) as authority for agent/ module code
- Merge dev's newer model names (MiniMax-M2.7, deepseek-v4-flash)
  with PR's blockrun provider entries
- Fix duplicate agent init in main.go, keep defer-based Stop()
- Fix var type bug in store/ai_model.go (model → models)
- Remove dev-only test files incompatible with PR's evolved agent code
  (to be re-synced after merge)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 16:52:04 +08:00
shinchan-zhai
ca8bed4a58 fix(agent): add TargetRef nil guards and ensureHistory for robustness
- Add nil checks for session.TargetRef in all four execute*Action
  handlers (Trader/Exchange/Model/Strategy) to prevent panic on
  corrupted sessions; bulk-delete and query actions are excluded
- Add ensureHistory() helper and call it in runPlannedAgentWithContextMode
  to prevent nil panic when history is not initialized

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 16:43:36 +08:00
shinchan-zhai
94844b7139 fix(agent): guard async maintenance goroutine and add timeout to diagnosis ctx
- Add stopCh check in runPostResponseMaintenanceAsync to respect agent
  shutdown, preventing goroutine leak on Agent.Stop()
- Replace bare context.Background() in handleTraderDiagnosisSkill with
  a 30s timeout context for proper deadline propagation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 16:37:30 +08:00
lky-spec
e67a927a4f Refine strategy creation flow and diagnostics 2026-05-09 14:48:24 +08:00
lky-spec
0f11be77f8 Improve NOFXi agent strategy creation flow 2026-05-06 17:00:05 +08:00
lky-spec
159f27dfdd Improve NOFXi agent product handling 2026-05-02 22:55:10 +08:00
lky-spec
25d0b30ea9 Split strategy config by strategy type 2026-04-28 20:19:24 +08:00
lky-spec
2d45e7ab15 Refine agent strategy routing and config handling 2026-04-28 19:37:44 +08:00
lky-spec
fc6c42ac11 Revert "Revert "Clean up reverted strategy prompt remnants""
This reverts commit 03a307939e.
2026-04-28 15:54:37 +08:00
lky-spec
5ff7212cb3 Revert "Revert "Trim agent planning tools and validate strategy patches""
This reverts commit 3619f82796.
2026-04-28 15:54:36 +08:00
lky-spec
3619f82796 Revert "Trim agent planning tools and validate strategy patches"
This reverts commit fe0dbce367.
2026-04-28 15:53:53 +08:00
lky-spec
03a307939e Revert "Clean up reverted strategy prompt remnants"
This reverts commit 8d8a0cc72b.
2026-04-28 15:53:53 +08:00
lky-spec
8d8a0cc72b Clean up reverted strategy prompt remnants 2026-04-28 15:50:45 +08:00
lky-spec
fe0dbce367 Trim agent planning tools and validate strategy patches 2026-04-28 15:45:47 +08:00
lky-spec
b536265f93 Propagate MCP request context to HTTP calls 2026-04-28 12:22:45 +08:00
lky-spec
30a703a827 Unify agent routing and tighten exchange config 2026-04-28 11:58:58 +08:00
lky-spec
d481b3d88c Remove local-only agent artifacts 2026-04-27 10:51:09 +08:00
lky-spec
e8eafce1e0 Require explicit agent mutation targets 2026-04-26 22:38:16 +08:00
lky-spec
ce3a8582af Simplify agent skill routing and config updates 2026-04-26 22:22:12 +08:00
lky-spec
cfd91069d3 Centralize active skill field extraction 2026-04-26 20:44:09 +08:00
lky-spec
903eb591eb Improve active skill schema handling 2026-04-26 11:58:29 +08:00
lky-spec
9ee931ee30 v2 2026-04-25 20:24:46 +08:00
lky-spec
c244e4cdf1 change v1 2026-04-25 16:18:45 +08:00
lky-spec
737f9bca95 Enhance NOFXi agent workflow and diagnostics 2026-04-19 16:06:28 +08:00
lky-spec
5c4e7502d7 feat: integrate NOFXi agent into dev 2026-04-18 16:06:42 +08:00
362 changed files with 28458 additions and 34273 deletions

View File

@@ -273,7 +273,11 @@ jobs:
uses: actions/checkout@v4
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
# SECURITY: pinned to a full 40-char commit SHA (v0.36.0) — a mutable
# version tag could be re-pointed by an upstream compromise (GHSA-69fq-xp46-6x23:
# trivy-action's published artifacts were briefly poisoned). The trailing
# comment records the human-readable version; Dependabot updates the SHA.
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: 'fs'
scan-ref: '.'
@@ -299,7 +303,11 @@ jobs:
fetch-depth: 0
- name: Run TruffleHog OSS
uses: trufflesecurity/trufflehog@main
# SECURITY: never use @main — upstream compromise = secret exfil.
# TODO: pin to a full 40-char SHA from
# https://github.com/trufflesecurity/trufflehog/releases and configure
# Dependabot. Version tag is still mutable but is a major upgrade over @main.
uses: trufflesecurity/trufflehog@v3.82.13
with:
path: ./
base: ${{ github.event.pull_request.base.sha }}

10
.gitignore vendored
View File

@@ -44,6 +44,7 @@ decision_logs/
nofx_test
# Node.js
web/node_modules
web/node_modules/
node_modules/
web/dist/
@@ -52,6 +53,9 @@ web/.vite/
# ESLint 临时报告文件(调试时生成,不纳入版本控制)
eslint-*.json
# 本地 Agent QA seed个人调试用不纳入版本控制
docs/qa/fixtures/agent_self_play_seed.zh-CN.json
# VS code
.vscode
@@ -129,3 +133,9 @@ PR_DESCRIPTION.md
# Go build artifacts
/nofx-server
.gstack/
# Local AI agent / skill scaffolding (not part of the runtime app)
.agents/
skills-lock.json
img.png

File diff suppressed because it is too large Load Diff

357
README.md
View File

@@ -1,22 +1,14 @@
<h1 align="center">NOFX</h1>
<p align="center"><strong>Backed by <a href="https://vergex.trade">vergex.trade</a></strong></p>
<p align="center">
<strong>Your personal AI trading assistant.</strong><br/>
<strong>Any market. Any model. Pay with USDC, not API keys.</strong>
<img src="docs/assets/nofx-banner.svg" alt="NOFX — AI trading terminal" width="100%"/>
</p>
<p align="center">
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
</p>
<p align="center">
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
<a href="https://x402.org"><img src="https://img.shields.io/badge/x402-USDC%20Payments-2775CA?style=flat" alt="x402"></a>
<a href="https://claw402.ai"><img src="https://img.shields.io/badge/Claw402-AI%20Gateway-FF6B35?style=flat" alt="Claw402"></a>
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Stars"></a>
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Release"></a>
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-E0483B?style=flat-square&labelColor=1A1813" alt="License"></a>
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/telegram-community-E0483B?style=flat-square&labelColor=1A1813&logo=telegram&logoColor=white" alt="Telegram"></a>
</p>
<p align="center">
@@ -29,122 +21,83 @@
<a href="docs/i18n/vi/README.md">Tiếng Việt</a>
</p>
---
<br/>
NOFX is an open-source **autonomous** AI trading assistant. Unlike traditional AI tools that require you to manually configure models, manage API keys, and wire up data sources — NOFX's AI **perceives markets, selects models, and fetches data entirely on its own**. Zero human intervention. You set the strategy, the AI handles everything else.
NOFX is an open-source trading terminal where the strategy is a language model. Each trader runs a continuous loop — read market structure, decide, execute, record the reasoning — while a Go runtime clamps every order to hard risk limits the model cannot override.
**Fully autonomous**: The AI decides which model to use, what market data to pull, when to trade — all by itself. No manual model configuration. No juggling API keys for different services. Just fund a USDC wallet and let it run.
What makes it different: **built-in [x402](https://x402.org) micropayments**. No API keys. Fund a USDC wallet and pay per request. Your wallet is your identity.
Traders compose freely: any model, any of nine exchanges, any strategy. Run several side by side and compare them on a public leaderboard by realized return. Everything runs on your own machine; exchange credentials are encrypted at rest and never leave it.
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
Open **http://127.0.0.1:3000**. Done.
The terminal opens at `http://127.0.0.1:3000`.
---
**First run**
## Quick Demo
1. Register — the first account becomes the owner of the instance.
2. Follow the guided launch: put **$1+ USDC** (Base network) in the AI fee wallet it creates for you, then connect Hyperliquid and deposit **$12+ USDC** to trade with.
3. Start **Autopilot**. The AI scans the market every few minutes and trades on its own; every decision appears on the dashboard as it happens. Stop it anytime with one click.
<p align="center">
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
<img src="screenshots/demo-cover.png" alt="NOFX quick demo video" width="900"/>
</a>
</p>
<br/>
<p align="center">
Click the cover image to watch the demo video.
</p>
## Register exchanges
---
NOFX is free and open source. Opening an account through the partner links below carries reduced trading fees and funds continued development.
## How x402 Works
Traditional flow: register account → buy credits → get API key → manage quota → rotate keys.
x402 flow:
```
Request → 402 (here's the price) → wallet signs USDC → retry → done
```
No accounts. No API keys. No prepaid credits. One wallet, every model.
### Built-in x402 Providers
| Provider | Chain | Models |
| :--------------------------------------------------------------------------------------------------------------------------------- | :---- | :-------------------------------------------------------------------- |
| <img src="web/public/icons/claw402.png" width="20" height="20" style="vertical-align: middle;"/> **[Claw402](https://claw402.ai)** | Base | GPT-5.4, Claude Opus, DeepSeek, Qwen, Grok, Gemini, Kimi — 15+ models |
---
## What It Does
| Feature | Description |
| :------------------ | :------------------------------------------------------------------------ |
| **Multi-AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — switch anytime |
| **Multi-Exchange** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
| **Strategy Studio** | Visual builder — coin sources, indicators, risk controls |
| **AI Competition** | AIs compete in real-time, leaderboard ranks performance |
| **Telegram Agent** | Chat with your trading assistant — streaming, tool calling, memory |
| **Dashboard** | Live positions, P/L, AI decision logs with Chain of Thought |
### Markets
Crypto · US Stocks · Forex · Metals
### Exchanges (CEX)
| Exchange | Status | Register (Fee Discount) |
| :-------------------------------------------------------------------------------------------------------------------- | :----: | :----------------------------------------------------------------------------------- |
| Exchange | Status | Register with fee discount |
| :---------------------------------------------------------------------------------------------------------------------------- | :----: | :---------------------------------------------------------------------------------- |
| <img src="web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [Register](https://www.binance.com/join?ref=NOFXENG) |
| <img src="web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Register](https://partner.bybit.com/b/83856) |
| <img src="web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [Register](https://www.okx.com/join/1865360) |
| <img src="web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Register](https://app.hyperliquid.xyz/join/AITRADING) |
| <img src="web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [Register](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
| <img src="web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [Register](https://www.kucoin.com/r/broker/CXEV7XKK) |
| <img src="web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [Register](https://www.gatenode.xyz/share/VQBGUAxY) |
### Exchanges (Perp-DEX)
| Exchange | Status | Register (Fee Discount) |
| :---------------------------------------------------------------------------------------------------------------------------- | :----: | :------------------------------------------------------ |
| <img src="web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Register](https://app.hyperliquid.xyz/join/AITRADING) |
| <img src="web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [Register](https://www.asterdex.com/en/referral/fdfc0e) |
| <img src="web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [Register](https://www.asterdex.com/en/referral/fdfc0e) |
| <img src="web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [Register](https://app.lighter.xyz/?referral=68151432) |
### AI Models (API Key Mode)
<br/>
| AI Model | Status | Get API Key |
| :--------------------------------------------------------------------------------------------------------------- | :----: | :-------------------------------------------------- |
| <img src="web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [Get API Key](https://platform.deepseek.com) |
| <img src="web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [Get API Key](https://dashscope.console.aliyun.com) |
| <img src="web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [Get API Key](https://platform.openai.com) |
| <img src="web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [Get API Key](https://console.anthropic.com) |
| <img src="web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [Get API Key](https://aistudio.google.com) |
| <img src="web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [Get API Key](https://console.x.ai) |
| <img src="web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [Get API Key](https://platform.moonshot.cn) |
| <img src="web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [Get API Key](https://platform.minimaxi.com) |
## Demo
### AI Models (x402 Mode — No API Key)
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505
15+ models via [Claw402](https://claw402.ai) — just a USDC wallet
<br/>
---
## The model proposes. The runtime disposes.
## Screenshots
Decisions come from a language model reading the [Claw402.ai](https://claw402.ai) · Vergex data stack: a live signal board that ranks every market with directional bias and signal strength, per-symbol Signal Lab deep signals, cost-basis and liquidation heatmaps that show where the crowd's fuel and walls sit, and real-time market net flow — cross-checked against raw candles and the trader's own live track record. Execution does not.
Every order passes through limits enforced in code, outside the model's reach:
| | |
| :----------------------- | :--------------------------------------------------------------------------------- |
| Position limits | Max concurrent positions, notional capped as a ratio of equity, one position per symbol |
| Leverage clamps | Hard caps applied at order-sizing time, independent of what the model requests |
| Exchange-side protection | Stop-loss and take-profit placed on the exchange immediately after every entry |
| Drawdown auto-close | Profitable positions that give back too much from their peak are closed |
| Trade throttling | Minimum hold times, per-symbol re-entry cooldowns, per-cycle and per-hour entry limits |
| Safe mode | Repeated model failures block new entries until the model recovers |
| Launch preflight | Model access, wallet funds, strategy, and exchange balances verified before a trader may start |
Each decision is stored with the model's full reasoning. There is no position without a paper trail.
<br/>
## Terminal
| | |
| :--- | :--- |
| **Autopilot** | Guided launch: fund, connect, deposit, start — with server-side preflight throughout |
| **Strategy Studio** | Style presets, coin universes, indicators, leverage, entry confidence, custom prompts |
| **Competition** | Public leaderboard ranked by realized return, each entry attributed to its model |
| **Dashboard** | Live positions, orders, statistics, and the reasoning behind every decision |
<details>
<summary><b>Config Page</b></summary>
<summary>Screenshots</summary>
| AI Models & Exchanges | Traders List |
| :----------------------------------------------------------: | :----------------------------------------------------------: |
| <img src="screenshots/config-ai-exchanges.png" width="400"/> | <img src="screenshots/config-traders-list.png" width="400"/> |
</details>
<details>
<summary><b>Dashboard</b></summary>
<br/>
| Overview | Market Chart |
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
@@ -154,162 +107,138 @@ Crypto · US Stocks · Forex · Metals
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
| <img src="screenshots/dashboard-trading-stats.png" width="400"/> | <img src="screenshots/dashboard-position-history.png" width="400"/> |
| Positions | Trader Details |
| :----------------------------------------------------------: | :---------------------------------------------------: |
| <img src="screenshots/dashboard-positions.png" width="400"/> | <img src="screenshots/details-page.png" width="400"/> |
</details>
<details>
<summary><b>Strategy Studio</b></summary>
| Strategy Editor | Indicators Config |
| :------------------------------------------------------: | :----------------------------------------------------------: |
| <img src="screenshots/strategy-studio.png" width="400"/> | <img src="screenshots/strategy-indicators.png" width="400"/> |
</details>
<details>
<summary><b>Competition</b></summary>
| Competition Mode |
| :-------------------------------------------------------: |
| <img src="screenshots/competition-page.png" width="400"/> |
| Competition | Configuration |
| :-------------------------------------------------------: | :-----------------------------------------------------------: |
| <img src="screenshots/competition-page.png" width="400"/> | <img src="screenshots/config-ai-exchanges.png" width="400"/> |
</details>
---
<br/>
## Models
Eight providers with your own keys — DeepSeek, OpenAI, Claude, Qwen, Gemini, Grok, Kimi, MiniMax — including custom endpoints and model names.
Or no keys at all: [Claw402](https://claw402.ai) meters model usage per call in USDC over the x402 protocol. A wallet on Base replaces every API key.
| Provider | Access |
| :------- | :----- |
| **Claw402** | [Pay-as-you-go AI models with official discount](https://claw402.ai) |
## Markets
Crypto perpetuals on all nine exchanges. On Hyperliquid, the same runtime also trades tokenized US equities, commodities, indices, FX, and pre-IPO perps — TSLA, NVDA, GOLD, SPX, EUR, OPENAI — alongside crypto.
<br/>
## Architecture
```
┌─────────────────────────────────────────────────┐
│ Trading Terminal │
│ React · TypeScript · TradingView │
│ Dashboard · Strategy Studio · Competition │
├─────────────────────────────────────────────────┤
│ API Server (Go) │
│ JWT auth · encrypted credential store │
├──────────────┬──────────────┬───────────────────┤
│ Strategy │ Autopilot │ Trader Runtime │
│ Engine │ Preflight │ Risk Engine │
├──────────────┴──────────────┴───────────────────┤
│ AI Model Layer │
│ DeepSeek · OpenAI · Claude · Qwen · Gemini │
│ Grok · Kimi · MiniMax · Claw402 (x402 USDC) │
├─────────────────────────────────────────────────┤
│ Exchange Connectivity │
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
│ KuCoin · Gate · Aster · Lighter │
└─────────────────────────────────────────────────┘
```
<br/>
## Install
### Linux / macOS
**Linux / macOS**
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
### Railway (Cloud)
**Railway**
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/nofx?referralCode=nofx)
### Docker
**Docker**
```bash
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
docker compose -f docker-compose.prod.yml up -d
```
### Windows
Install [Docker Desktop](https://www.docker.com/products/docker-desktop/), then:
**Windows** — install [Docker Desktop](https://www.docker.com/products/docker-desktop/), then:
```powershell
curl -o docker-compose.prod.yml https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
docker compose -f docker-compose.prod.yml up -d
```
### From Source
**From source** — Go 1.21+, Node.js 18+:
```bash
# Prerequisites: Go 1.21+, Node.js 18+, TA-Lib
# macOS: brew install ta-lib
# Ubuntu: sudo apt-get install libta-lib0-dev
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
go build -o nofx && ./nofx # backend
cd web && npm install && npm run dev # frontend (new terminal)
cd web && npm install && npm run dev # frontend, in a second terminal
```
### Update
**Update** — re-run the install script; it upgrades in place.
<details>
<summary>Server deployment</summary>
<br/>
**HTTP**
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
# http://YOUR_IP:3000
```
---
**HTTPS via Cloudflare**
## Setup
**Beginner mode**: First-time users get a guided onboarding flow — select beginner mode at registration and the system walks you through AI, exchange, and strategy setup step by step.
**Advanced mode**:
1. **AI** — Add API keys or configure x402 wallet
2. **Exchange** — Connect exchange API credentials
3. **Strategy** — Build in Strategy Studio
4. **Trader** — Combine AI + Exchange + Strategy
5. **Trade** — Launch from the dashboard
Everything through the web UI at **http://127.0.0.1:3000**.
---
## Deploy to Server
**HTTP (quick):**
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
# Access via http://YOUR_IP:3000
```
**HTTPS (Cloudflare):**
1. Add domain to [Cloudflare](https://dash.cloudflare.com) (free plan)
2. A record → your server IP (Proxied)
1. Add the domain to [Cloudflare](https://dash.cloudflare.com) (free plan)
2. A record → server IP, proxied
3. SSL/TLS → Flexible
4. Set `TRANSPORT_ENCRYPTION=true` in `.env`
4. `TRANSPORT_ENCRYPTION=true` in `.env`
---
</details>
## Architecture
<br/>
```
NOFX
┌─────────────────────────────────────────────────┐
│ Web Dashboard │
│ React + TypeScript + TradingView │
├─────────────────────────────────────────────────┤
│ API Server (Go) │
├──────────┬──────────┬──────────┬────────────────┤
│ Strategy │ Telegram │
│ Engine │ Agent │
├──────────┴──────────┴──────────┴────────────────┤
│ MCP AI Client Layer │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │ API Key │ │ x402 │ │ │ │
│ │ DeepSeek │ │ Claw402 │ │ │ │
│ │ GPT,Claude │ │ │ │ │ │
│ └───────────┘ └───────────┘ └───────────┘ │
├─────────────────────────────────────────────────┤
│ Exchange Connectors │
│ Binance · Bybit · OKX · Bitget · KuCoin · Gate │
│ Hyperliquid · Aster DEX · Lighter │
└─────────────────────────────────────────────────┘
```
---
## Docs
## Documentation
| | |
| :------------------------------------------------------ | :------------------------------------ |
| [Getting Started](docs/getting-started/README.md) | Deployment and exchange API guides |
| [Architecture](docs/architecture/README.md) | System design and module index |
| [Strategy Module](docs/architecture/STRATEGY_MODULE.md) | Coin selection, AI prompts, execution |
| [FAQ](docs/faq/README.md) | Common questions |
| [Getting Started](docs/getting-started/README.md) | Deployment guide |
| [FAQ](docs/guides/faq.en.md) | Common questions |
| [Troubleshooting](docs/guides/TROUBLESHOOTING.md) | Diagnosing common issues |
---
## Community
[Telegram](https://t.me/nofx_dev_community) · [Twitter/X](https://x.com/vergex_ai) · [Issues](https://github.com/NoFxAiOS/nofx/issues) · [vergex.trade](https://vergex.trade) · [Live dashboard](https://vergex.trade/explore)
## Contributing
See [Contributing Guide](CONTRIBUTING.md) · [Code of Conduct](CODE_OF_CONDUCT.md) · [Security Policy](SECURITY.md)
Code, documentation, translations, and bug reports are all welcome — see the [Contributing Guide](CONTRIBUTING.md), [Code of Conduct](CODE_OF_CONDUCT.md), and [Security Policy](SECURITY.md).
### Contributor Airdrop Program
All contributions are tracked. When NOFX generates revenue, contributors receive airdrops.
**[Pinned Issues](https://github.com/NoFxAiOS/nofx/issues) get the highest rewards.**
NOFX tracks meaningful contributions and intends to reward contributors as the ecosystem grows. Priority issues carry higher weight.
| Contribution | Weight |
| :---------------- | :----: |
@@ -320,21 +249,9 @@ All contributions are tracked. When NOFX generates revenue, contributors receive
| Bug Reports | ★★ |
| Documentation | ★★ |
---
## Links
| | |
| :-------- | :---------------------------------------------------- |
| Website | [nofxai.com](https://nofxai.com) |
| Dashboard | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
| API Docs | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
| Twitter | [@nofx_official](https://x.com/nofx_official) |
> **Risk Warning**: AI auto-trading carries significant risks. Recommended for learning/research or small amounts only.
---
<a href="https://github.com/NoFxAiOS/nofx/graphs/contributors">
<img src="https://contrib.rocks/image?repo=NoFxAiOS/nofx" alt="Contributors"/>
</a>
## Sponsors
@@ -352,8 +269,14 @@ All contributions are tracked. When NOFX generates revenue, contributors receive
[Become a sponsor](https://github.com/sponsors/NoFxAiOS)
<br/>
If NOFX is useful to you, a star helps other traders find it.
[![Star History Chart](https://api.star-history.com/svg?repos=NoFxAiOS/nofx&type=Date)](https://star-history.com/#NoFxAiOS/nofx&Date)
## License
[AGPL-3.0](LICENSE)
[![Star History Chart](https://api.star-history.com/svg?repos=NoFxAiOS/nofx&type=Date)](https://star-history.com/#NoFxAiOS/nofx&Date)
<sub>Automated trading involves substantial risk. AI-driven strategies are experimental and can lose money. Size positions appropriately, understand each venue, and never trade funds you cannot afford to lose. Full [disclaimer](DISCLAIMER.md).</sub>

View File

@@ -1,825 +0,0 @@
// Package agent implements the NOFXi Agent Core.
//
// Architecture: ALL user messages go to the LLM. The LLM understands intent
// and calls tools to execute actions. No regex routing, no pattern matching.
// The LLM IS the brain — just like how OpenClaw works.
package agent
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"time"
"nofx/manager"
"nofx/market"
"nofx/mcp"
"nofx/store"
)
type Agent struct {
traderManager *manager.TraderManager
store *store.Store
aiClient mcp.AIClient
config *Config
sentinel *Sentinel
brain *Brain
scheduler *Scheduler
logger *slog.Logger
history *chatHistory
pending *pendingTrades
stopCh chan struct{} // signals background goroutines to stop
stopOnce sync.Once
NotifyFunc func(userID int64, text string) error
}
type Config struct {
Language string `json:"language"`
WatchSymbols []string `json:"watch_symbols"`
EnableBriefs bool `json:"enable_briefs"`
EnableNews bool `json:"enable_news"`
EnableSentinel bool `json:"enable_sentinel"`
BriefTimes []int `json:"brief_times"`
}
func DefaultConfig() *Config {
return &Config{
Language: "zh", WatchSymbols: []string{"BTCUSDT", "ETHUSDT", "SOLUSDT"},
EnableBriefs: true, EnableNews: true, EnableSentinel: true, BriefTimes: []int{8, 20},
}
}
func New(tm *manager.TraderManager, st *store.Store, cfg *Config, logger *slog.Logger) *Agent {
if cfg == nil {
cfg = DefaultConfig()
}
return &Agent{traderManager: tm, store: st, config: cfg, logger: logger, history: newChatHistory(100), pending: newPendingTrades(), stopCh: make(chan struct{})}
}
func (a *Agent) SetAIClient(c mcp.AIClient) { a.aiClient = c }
func (a *Agent) ensureHistory() {
if a.history == nil {
a.history = newChatHistory(100)
}
}
func (a *Agent) log() *slog.Logger {
if a != nil && a.logger != nil {
return a.logger
}
return slog.Default()
}
func (a *Agent) EnsureAIClient() {
a.ensureAIClientForStoreUser("default")
}
func (a *Agent) ensureAIClientForStoreUser(storeUserID string) {
if storeUserID == "" {
storeUserID = "default"
}
if a.store != nil {
if client, modelName, ok := a.loadAIClientFromStoreUser(storeUserID); ok {
a.aiClient = client
a.log().Info("agent AI client ready", "store_user_id", storeUserID, "model", modelName)
return
}
}
if a.aiClient != nil {
a.log().Warn("clearing stale AI client for store user", "store_user_id", storeUserID)
a.aiClient = nil
}
a.log().Warn("no AI client — agent will have limited capabilities", "store_user_id", storeUserID)
}
func (a *Agent) loadAIClientFromStoreUser(storeUserID string) (mcp.AIClient, string, bool) {
if a.store == nil {
a.log().Warn("cannot load AI client: store unavailable", "store_user_id", storeUserID)
return nil, "", false
}
if storeUserID == "" {
storeUserID = "default"
}
model, err := a.store.AIModel().GetDefault(storeUserID)
if err != nil || model == nil {
a.log().Warn("no enabled AI model found for store user", "store_user_id", storeUserID, "error", err)
return nil, "", false
}
a.log().Info(
"agent selected AI model config",
"store_user_id", storeUserID,
"model_id", model.ID,
"provider", model.Provider,
"enabled", model.Enabled,
"has_api_key", len(model.APIKey) > 0,
"custom_api_url", strings.TrimSpace(model.CustomAPIURL),
"custom_model_name", strings.TrimSpace(model.CustomModelName),
)
apiKey := string(model.APIKey)
customAPIURL := strings.TrimSpace(model.CustomAPIURL)
modelName := strings.TrimSpace(model.CustomModelName)
provider := strings.ToLower(strings.TrimSpace(model.Provider))
// Use the provider registry for providers like claw402 that have their own
// client implementation (x402 payment, custom auth, etc.).
if client := mcp.NewAIClientByProvider(provider); client != nil {
if modelName == "" {
modelName = model.ID
}
client.SetAPIKey(apiKey, customAPIURL, modelName)
return client, modelName, true
}
customAPIURL, modelName = resolveModelRuntimeConfig(provider, customAPIURL, modelName, model.ID)
if apiKey == "" || customAPIURL == "" {
a.log().Warn(
"enabled AI model is incomplete",
"store_user_id", storeUserID,
"model_id", model.ID,
"provider", model.Provider,
"has_api_key", apiKey != "",
"has_custom_api_url", customAPIURL != "",
)
return nil, "", false
}
httpClient := &http.Client{Timeout: 60 * time.Second}
client := mcp.NewClient(mcp.WithHTTPClient(httpClient))
name := modelName
client.SetAPIKey(apiKey, customAPIURL, name)
return client, name, true
}
func resolveModelRuntimeConfig(provider, customAPIURL, customModelName, fallbackModelID string) (string, string) {
provider = strings.ToLower(strings.TrimSpace(provider))
customAPIURL = strings.TrimSpace(customAPIURL)
customModelName = strings.TrimSpace(customModelName)
fallbackModelID = strings.TrimSpace(fallbackModelID)
type providerDefaults struct {
url string
model string
}
defaults := map[string]providerDefaults{
"deepseek": {url: "https://api.deepseek.com/v1", model: "deepseek-chat"},
"qwen": {url: "https://dashscope.aliyuncs.com/compatible-mode/v1", model: "qwen3-max"},
"openai": {url: "https://api.openai.com/v1", model: "gpt-5.2"},
"claude": {url: "https://api.anthropic.com/v1", model: "claude-opus-4-6"},
"gemini": {url: "https://generativelanguage.googleapis.com/v1beta/openai", model: "gemini-3-pro-preview"},
"grok": {url: "https://api.x.ai/v1", model: "grok-3-latest"},
"kimi": {url: "https://api.moonshot.ai/v1", model: "moonshot-v1-auto"},
"minimax": {url: "https://api.minimax.chat/v1", model: "MiniMax-M2.5"},
}
if customAPIURL == "" {
if cfg, ok := defaults[provider]; ok {
customAPIURL = cfg.url
}
}
if customModelName == "" {
if cfg, ok := defaults[provider]; ok {
customModelName = cfg.model
}
}
if customModelName == "" {
customModelName = fallbackModelID
}
return customAPIURL, customModelName
}
func (a *Agent) Start() {
a.logger.Info("starting NOFXi agent...")
a.EnsureAIClient()
if a.config.EnableSentinel {
a.sentinel = NewSentinel(a.config.WatchSymbols, a.handleSignal, a.logger)
a.sentinel.Start()
}
a.brain = NewBrain(a, a.logger)
if a.config.EnableNews {
a.brain.StartNewsScan(5 * time.Minute)
}
if a.config.EnableBriefs {
a.brain.StartMarketBriefs(a.config.BriefTimes)
}
a.scheduler = NewScheduler(a, a.logger)
a.scheduler.Start(context.Background())
a.logger.Info("NOFXi agent is online 🚀")
}
func (a *Agent) Stop() {
// Signal all background goroutines (e.g. chat-history-cleanup) to exit.
a.stopOnce.Do(func() { close(a.stopCh) })
if a.sentinel != nil {
a.sentinel.Stop()
}
if a.brain != nil {
a.brain.Stop()
}
if a.scheduler != nil {
a.scheduler.Stop()
}
}
// HandleMessage — the core. Everything goes through the LLM.
func (a *Agent) HandleMessage(ctx context.Context, userID int64, text string) (string, error) {
a.EnsureAIClient()
return a.handleMessageForStoreUser(ctx, "default", userID, text)
}
// HandleMessageForStoreUser is like HandleMessage but stores setup artifacts
// (exchange/model) under the provided authenticated store user ID.
func (a *Agent) HandleMessageForStoreUser(ctx context.Context, storeUserID string, userID int64, text string) (string, error) {
return a.handleMessageForStoreUser(ctx, storeUserID, userID, text)
}
func (a *Agent) handleMessageForStoreUser(ctx context.Context, storeUserID string, userID int64, text string) (string, error) {
a.ensureAIClientForStoreUser(storeUserID)
lang := a.config.Language
if strings.HasPrefix(text, "[lang:") {
if end := strings.Index(text, "] "); end > 0 {
lang = text[6:end]
text = text[end+2:]
}
}
a.logger.Info("message", "user_id", userID, "text", text)
// Only keep a tiny command surface outside the planner.
if text == "/status" {
return a.handleStatus(lang), nil
}
if text == "/clear" {
a.history.Clear(userID)
a.clearTaskState(userID)
a.clearExecutionState(userID)
if lang == "zh" {
return "🧹 对话记忆已清除。", nil
}
return "🧹 Conversation history cleared.", nil
}
if reply, handled := a.handleTradeConfirmation(ctx, userID, text, lang); handled {
return reply, nil
}
// Everything else goes through the planner and tool system.
return a.thinkAndAct(ctx, storeUserID, userID, lang, text)
}
// HandleMessageStream is like HandleMessage but streams the final LLM response via SSE.
// onEvent is called with (eventType, data) — see StreamEvent* constants.
// Non-streamable responses (commands, trade confirmations) return immediately without events.
func (a *Agent) HandleMessageStream(ctx context.Context, userID int64, text string, onEvent func(event, data string)) (string, error) {
a.EnsureAIClient()
return a.handleMessageStreamForStoreUser(ctx, "default", userID, text, onEvent)
}
// HandleMessageStreamForStoreUser mirrors HandleMessageForStoreUser for SSE responses.
func (a *Agent) HandleMessageStreamForStoreUser(ctx context.Context, storeUserID string, userID int64, text string, onEvent func(event, data string)) (string, error) {
return a.handleMessageStreamForStoreUser(ctx, storeUserID, userID, text, onEvent)
}
func (a *Agent) handleMessageStreamForStoreUser(ctx context.Context, storeUserID string, userID int64, text string, onEvent func(event, data string)) (string, error) {
a.ensureAIClientForStoreUser(storeUserID)
lang := a.config.Language
if strings.HasPrefix(text, "[lang:") {
if end := strings.Index(text, "] "); end > 0 {
lang = text[6:end]
text = text[end+2:]
}
}
a.logger.Info("message (stream)", "user_id", userID, "text", text)
if text == "/status" {
return a.handleStatus(lang), nil
}
if text == "/clear" {
a.history.Clear(userID)
a.clearTaskState(userID)
a.clearExecutionState(userID)
if lang == "zh" {
return "🧹 对话记忆已清除。", nil
}
return "🧹 Conversation history cleared.", nil
}
if reply, handled := a.handleTradeConfirmation(ctx, userID, text, lang); handled {
if onEvent != nil {
onEvent(StreamEventDelta, reply)
}
return reply, nil
}
return a.thinkAndActStream(ctx, storeUserID, userID, lang, text, onEvent)
}
// StreamEvent types sent via SSE to the frontend.
const (
StreamEventPlanning = "planning"
StreamEventPlan = "plan"
StreamEventStepStart = "step_start"
StreamEventStepComplete = "step_complete"
StreamEventReplan = "replan"
StreamEventTool = "tool" // Tool is being called (shows status to user)
StreamEventDelta = "delta" // Text chunk from LLM streaming
StreamEventDone = "done" // Stream complete
StreamEventError = "error" // Error occurred
)
// buildSystemPrompt creates the system prompt that makes NOFXi behave like a real agent.
func (a *Agent) buildSystemPrompt(lang string) string {
// Gather live system state
traderInfo := a.getTradersSummary()
watchlist := ""
if a.sentinel != nil {
watchlist = a.sentinel.FormatWatchlist(lang)
}
skillCatalog := skillCatalogPrompt(lang)
if lang == "zh" {
return fmt.Sprintf(`你是 NOFXi一个专业的 AI 交易 Agent。你不是一个简单的聊天机器人——你是用户的交易伙伴。
## 你的核心能力
1. **市场分析** — 加密货币BTC/ETH/SOL等有实时数据A股/港股/美股/外汇你可以基于知识分析
2. **交易管理** — 查看持仓、余额、交易历史、Trader 状态
3. **策略建议** — 根据用户需求制定交易策略
4. **策略模板管理** — 创建、查看、修改、删除、激活策略模板
5. **风险管理** — 评估风险、建议止损止盈
6. **配置引导** — 用户说"开始配置"时引导配置交易所和AI模型
## 当前系统状态
%s
%s
## 数据说明(极其重要,违反即失职!)
- 加密货币BTC/ETH等交易所实时数据标注 [Real-time]
- A股/港股/美股:**必须调用 search_stock 工具**获取实时行情。不调工具就没有数据。
- 美股盘前盘后search_stock 返回的 quote 中 ext_price/ext_change_pct/ext_time
- 外汇/指数期货:当前没有数据源,如实告知
### 铁律:禁止编造任何价格!
- **你的训练数据中的价格全部过时,不可使用**
- **没有通过工具获取的价格 = 你不知道 = 不能说**
- 用户问多只股票的盘前数据?→ 对每只股票调用 search_stock 工具
- 用户问"盘前概览"?→ 调用 search_stock 查主要股票AAPL、TSLA、NVDA、MSFT、GOOGL、AMZN、META等用真实数据回答
- **绝对不允许**不调工具就给出具体价格数字(如 $421.85
- 如果某只股票 search_stock 查不到数据,就说"暂时无法获取该股票数据"
- 指数期货(纳指、标普、道琼斯期货)我们目前没有数据源,直接说"暂不支持指数期货数据"
## 工具使用
你可以调用以下工具来执行操作:
- **search_stock** — 搜索股票(支持中文名、英文名、代码)。当用户提到你不认识的股票时,先用这个工具搜索。
- **execute_trade** — 下单交易加密货币或美股。美股open_long=买入close_long=卖出。调用后创建待确认订单,用户需回复"确认 trade_xxx"。
- **get_positions** — 查看当前所有持仓(加密货币 + 股票)
- **get_balance** — 查看账户余额
- **get_market_price** — 获取实时价格(加密货币或股票代码)
- **get_exchange_configs / manage_exchange_config** — 查看、新增、修改、删除交易所绑定配置
- **get_model_configs / manage_model_config** — 查看、新增、修改、删除 AI 模型配置
- **get_strategies / manage_strategy** — 查看、新增、修改、删除、激活、复制策略模板
- **manage_trader** — 查看、新增、修改、删除、启动、停止交易员
### 配置、策略与交易员管理规则
- 当用户要求创建、修改、删除、激活、复制策略模板时,优先使用 get_strategies / manage_strategy
- **策略模板本身是独立资源,不默认依赖交易所或 AI 模型**
- 只有当用户要求“运行策略 / 创建交易员 / 把策略部署到账户”时,才需要进一步关联交易所、模型或 trader
- 当用户要求配置交易所、绑定 API Key、修改交易所账户时优先使用 manage_exchange_config
- 当用户要求配置大模型、设置 API Key、切换模型、修改模型地址时优先使用 manage_model_config
- 当用户要求创建、修改、删除、启动、停止交易员时,优先使用 manage_trader
- 如果缺少必要字段,先追问缺失信息,再调用工具
- **在这些工具存在时,不要说“系统没有这个能力”**
- 对敏感信息API Key、Secret、Private Key只保存不要在最终回复中完整回显
%s
### 交易安全规则
- 用户明确要求交易时才调用 execute_trade
- 分析和建议不需要调用工具,直接回复即可
- 交易确认信息要清晰展示:品种、方向、数量、杠杆
- 提醒用户确认命令格式
### 数据真实性规则(极其重要!)
- **持仓信息必须且只能通过 get_positions 工具获取**,绝对禁止编造持仓
- **余额信息必须且只能通过 get_balance 工具获取**,绝对禁止编造余额
- 如果用户问持仓但 get_positions 返回空,就说"当前没有持仓",不要编造
- 如果工具返回 error如未配置交易所如实告知用户
- **你不知道用户持有什么股票/币种,除非工具返回了数据**
- 查股票行情 ≠ 用户持有该股票。不要混淆"查价格"和"有持仓"
## 行为准则
- 简洁、专业、有观点。不说废话。
- 用户问什么答什么,不要推销配置。
- 有实时数据时给具体价位,没有时给策略框架和思路。
- **诚实是第一原则** — 不确定就说不确定,没数据就说没数据。绝不编造。
- 用交易相关的 emoji 让回复更直观。
- 用中文回复。
当前时间: %s`, traderInfo, watchlist, skillCatalog, time.Now().Format("2006-01-02 15:04:05"))
}
return fmt.Sprintf(`You are NOFXi, a professional AI trading agent. Not a chatbot — a trading partner.
## Capabilities
1. Market analysis — crypto with real-time data, stocks/forex with knowledge
2. Trade management — positions, balance, history, trader status
3. Strategy — build trading strategies based on user needs
4. Strategy template management — create, inspect, update, delete, and activate strategy templates
5. Risk management — assess risk, suggest stop-loss/take-profit
6. Setup — guide exchange/AI configuration when user asks
## Current System State
%s
%s
## Data Notice (CRITICAL — violating this is unacceptable!)
- Crypto (BTC/ETH): Exchange real-time data, marked [Real-time]
- Stocks: You MUST call search_stock tool to get real-time quotes. No tool call = no data.
- US stocks pre/after-hours: ext_price/ext_change_pct/ext_time in search_stock results
- Forex/Index futures: No data source currently — tell user honestly
### ABSOLUTE RULE: NEVER fabricate any price!
- Your training data prices are ALL outdated and MUST NOT be used
- No tool result = you don't know = you cannot state a price
- User asks multiple stocks? → Call search_stock for EACH one
- User asks "pre-market overview"? → Call search_stock for major stocks (AAPL, TSLA, NVDA, MSFT, GOOGL, AMZN, META etc.) and use real data
- NEVER output a specific price number (like $421.85) without a tool having returned it
- If search_stock fails for a stock, say "unable to fetch data for this stock"
- Index futures (NDX, SPX, DJI futures) — we have no data source, say "index futures not supported yet"
## Tools
You can call these tools to take action:
- **search_stock** — Search for stocks by name, ticker, or code. Covers A-share, HK, and US markets. Use when the user mentions an unknown stock.
- **execute_trade** — Place a trade order (crypto or US stocks). For stocks: open_long=buy, close_long=sell. Creates a pending order that requires user confirmation.
- **get_positions** — View all current open positions (crypto + stocks)
- **get_balance** — View account balance and equity
- **get_market_price** — Get real-time price from the exchange (crypto or stock symbol)
- **get_exchange_configs / manage_exchange_config** — View, create, update, and delete exchange bindings
- **get_model_configs / manage_model_config** — View, create, update, and delete AI model bindings
- **get_strategies / manage_strategy** — View, create, update, delete, activate, and duplicate strategy templates
- **manage_trader** — List, create, update, delete, start, and stop traders
### Configuration, Strategy, and Trader Rules
- When the user wants to create, edit, delete, activate, or duplicate a strategy template, prefer get_strategies / manage_strategy
- **A strategy template is an independent asset and does not require exchange or model bindings by default**
- Only ask for exchange/model/trader details when the user wants to run, deploy, or attach a strategy to a trader
- When the user wants to bind or edit an exchange account, prefer manage_exchange_config
- When the user wants to bind or edit an AI model, prefer manage_model_config
- When the user wants to create, edit, delete, start, or stop a trader, prefer manage_trader
- If required fields are missing, ask a focused follow-up question first, then call the tool
- **Do not claim the system lacks these capabilities when the tools exist**
- For secrets such as API keys, secrets, and private keys: store them, but never echo them back in full
%s
### Trade Safety Rules
- Only call execute_trade when user explicitly requests a trade
- Analysis and advice don't need tools — just reply directly
- Show trade details clearly: symbol, direction, quantity, leverage
- Remind user of the confirmation command format
### Data Truthfulness Rules (CRITICAL!)
- **Position data MUST come from get_positions tool only** — NEVER fabricate positions
- **Balance data MUST come from get_balance tool only** — NEVER fabricate balances
- If get_positions returns empty, say "no open positions" — do NOT make up holdings
- If a tool returns an error (e.g. no exchange configured), tell the user honestly
- **You do NOT know what the user holds unless a tool tells you**
- Checking a stock price ≠ user owns that stock. Never confuse "quote lookup" with "holding"
## Behavior
- Concise, professional, opinionated. No fluff.
- Answer what's asked. Don't push setup.
- With real-time data: give specific levels. Without: give strategy frameworks.
- **Honesty is rule #1** — uncertain = say uncertain, no data = say no data.
- Use trading emojis.
Current time: %s`, traderInfo, watchlist, skillCatalog, time.Now().Format("2006-01-02 15:04:05"))
}
// gatherContext collects real-time market data relevant to the user's message.
func (a *Agent) gatherContext(text string) string {
var parts []string
upper := strings.ToUpper(text)
// Crypto — detect symbols dynamically
// 1. Check known popular symbols (fast path)
// 2. Extract any "XXXUSDT" pattern from text (catches arbitrary pairs)
knownSymbols := []string{
"BTC", "ETH", "SOL", "BNB", "XRP", "DOGE", "ADA", "AVAX", "DOT", "LINK",
"PEPE", "SHIB", "ARB", "OP", "SUI", "APT", "SEI", "TIA", "JUP", "WIF",
"NEAR", "ATOM", "FTM", "MATIC", "INJ", "RENDER", "FET", "TAO", "WLD",
"AAVE", "UNI", "LDO", "MKR", "CRV", "PENDLE", "ENA", "ONDO", "TRUMP",
}
matched := make(map[string]bool)
for _, sym := range knownSymbols {
if strings.Contains(upper, sym) {
matched[sym] = true
}
}
// Also extract "XXXUSDT" patterns for coins not in the known list
for _, word := range strings.Fields(upper) {
word = strings.Trim(word, ".,!?;:()[]{}\"'")
if strings.HasSuffix(word, "USDT") && len(word) > 4 && len(word) <= 15 {
sym := strings.TrimSuffix(word, "USDT")
if len(sym) >= 2 && len(sym) <= 10 {
matched[sym] = true
}
}
}
// Collect and sort matched symbols for deterministic selection
sortedSymbols := make([]string, 0, len(matched))
for sym := range matched {
sortedSymbols = append(sortedSymbols, sym)
}
sort.Strings(sortedSymbols)
// Cap at 5 symbols to avoid slow context gathering
count := 0
for _, sym := range sortedSymbols {
if count >= 5 {
break
}
md, err := market.Get(sym + "USDT")
if err == nil && md.CurrentPrice > 0 {
parts = append(parts, fmt.Sprintf("[%s/USDT Real-time]\nPrice: $%.4f | 1h: %+.2f%% | 4h: %+.2f%% | RSI7: %.1f | EMA20: %.4f | MACD: %.6f | Funding: %.4f%%",
sym, md.CurrentPrice, md.PriceChange1h, md.PriceChange4h, md.CurrentRSI7, md.CurrentEMA20, md.CurrentMACD, md.FundingRate*100))
count++
}
}
// A-share / stocks — only call Sina API when text likely references stocks.
// Skip for purely crypto conversations to avoid unnecessary external API calls.
if looksLikeStockQuery(text) {
stockCode, stockName := resolveStockCodeDynamic(text)
if stockCode != "" {
quote, err := fetchStockQuote(stockCode)
if err == nil && quote.Price > 0 {
parts = append(parts, fmt.Sprintf("[%s(%s) Real-time A-share Data]\n%s", quote.Name, quote.Code, formatStockQuote(quote)))
} else if err != nil {
a.logger.Error("fetch stock quote", "code", stockCode, "name", stockName, "error", err)
}
}
}
// Trader positions
if a.traderManager != nil {
for _, t := range a.traderManager.GetAllTraders() {
positions, err := t.GetPositions()
if err != nil {
continue
}
for _, p := range positions {
size := toFloat(p["size"])
if size == 0 {
continue
}
parts = append(parts, fmt.Sprintf("[Position] %s %s: size=%.4f entry=$%.4f mark=$%.4f pnl=$%.2f",
p["symbol"], p["side"], size, toFloat(p["entryPrice"]), toFloat(p["markPrice"]), toFloat(p["unrealizedPnl"])))
}
}
}
return strings.Join(parts, "\n")
}
func (a *Agent) getTradersSummary() string {
if a.traderManager == nil {
return "Traders: none configured"
}
traders := a.traderManager.GetAllTraders()
if len(traders) == 0 {
return "Traders: none configured"
}
var lines []string
for id, t := range traders {
s := t.GetStatus()
running, _ := s["is_running"].(bool)
status := "stopped"
if running {
status = "running"
}
tid := id
if len(tid) > 8 {
tid = tid[:8]
}
lines = append(lines, fmt.Sprintf("• %s [%s] %s | %s", t.GetName(), tid, status, t.GetExchange()))
}
return "Traders:\n" + strings.Join(lines, "\n")
}
func (a *Agent) handleStatus(L string) string {
tc, rc := 0, 0
if a.traderManager != nil {
all := a.traderManager.GetAllTraders()
tc = len(all)
for _, t := range all {
if s := t.GetStatus(); s["is_running"] == true {
rc++
}
}
}
wc := 0
if a.sentinel != nil {
wc = a.sentinel.SymbolCount()
}
ai := "❌"
if a.aiClient != nil {
ai = "✅"
}
return fmt.Sprintf(a.msg(L, "status"), rc, tc, wc, ai, time.Now().Format("2006-01-02 15:04:05"))
}
// noAIFallback — when no AI is available, still try to be useful.
func (a *Agent) noAIFallback(lang, text string) (string, error) {
upper := strings.ToUpper(text)
// Try to provide market data directly
for _, sym := range []string{"BTC", "ETH", "SOL", "BNB", "XRP", "DOGE"} {
if strings.Contains(upper, sym) {
md, err := market.Get(sym + "USDT")
if err == nil {
return fmt.Sprintf("📊 *%s/USDT*\n\n%s\n\n💡 配置 AI 模型后我能给你更深度的分析。发送 *开始配置* 开始。", sym, market.Format(md)), nil
}
}
}
// Check if asking about positions/balance
if strings.Contains(text, "持仓") || strings.Contains(upper, "POSITION") {
return a.queryPositionsDirect(lang)
}
if strings.Contains(text, "余额") || strings.Contains(upper, "BALANCE") {
return a.queryBalancesDirect(lang)
}
if lang == "zh" {
return "🤖 我是 NOFXi。配置 AI 模型后我就能理解你的任何问题——分析股票、制定策略、管理交易。\n\n现在可用\n• 加密货币实时行情试试「BTC」\n• `/status` 系统状态\n\n发送 *开始配置* 配置 AI 模型。", nil
}
return "🤖 I'm NOFXi. Configure an AI model and I can understand anything — analyze stocks, build strategies, manage trades.\n\nAvailable now:\n• Crypto real-time data (try 'BTC')\n• `/status` system status\n\nSend *setup* to configure AI.", nil
}
func (a *Agent) aiServiceFailure(lang string, err error) (string, error) {
reason := "unknown error"
if err != nil {
reason = summarizeObservation(err.Error())
}
a.logger.Error("AI service call failed", "error", reason)
if lang == "zh" {
return fmt.Sprintf("当前 AI 服务调用失败:%s\n\n这不是“未配置模型”。更可能是模型服务余额不足、接口报错或超时。请检查当前启用模型的 API 状态后再试。", reason), nil
}
return fmt.Sprintf("The AI service call failed: %s\n\nThis is not a missing-model issue. The active model provider likely returned an error, timed out, or has insufficient balance. Please check the active model API and try again.", reason), nil
}
func (a *Agent) queryPositionsDirect(L string) (string, error) {
if a.traderManager == nil {
return a.msg(L, "no_traders"), nil
}
var sb strings.Builder
sb.WriteString("📊 *Positions*\n\n")
hasAny := false
for id, t := range a.traderManager.GetAllTraders() {
positions, err := t.GetPositions()
if err != nil {
continue
}
for _, p := range positions {
size := toFloat(p["size"])
if size == 0 {
continue
}
hasAny = true
pnl := toFloat(p["unrealizedPnl"])
e := "🟢"
if pnl < 0 {
e = "🔴"
}
tid := id
if len(tid) > 8 {
tid = tid[:8]
}
sb.WriteString(fmt.Sprintf("%s *%s* %s — $%.2f | Trader: %s\n", e, p["symbol"], p["side"], pnl, tid))
}
}
if !hasAny {
return a.msg(L, "no_positions"), nil
}
return sb.String(), nil
}
func (a *Agent) queryBalancesDirect(L string) (string, error) {
if a.traderManager == nil {
return a.msg(L, "no_traders"), nil
}
var sb strings.Builder
sb.WriteString("💰 *Balance*\n\n")
for id, t := range a.traderManager.GetAllTraders() {
info, err := t.GetAccountInfo()
if err != nil {
continue
}
tid := id
if len(tid) > 8 {
tid = tid[:8]
}
sb.WriteString(fmt.Sprintf("*%s* (%s): $%.2f\n", t.GetName(), tid, toFloat(info["total_equity"])))
}
return sb.String(), nil
}
func (a *Agent) handleSignal(sig Signal) {
if a.brain != nil {
a.brain.HandleSignal(sig)
}
}
func (a *Agent) notifyAll(text string) {
if a.NotifyFunc != nil {
a.NotifyFunc(0, text)
}
}
// looksLikeStockQuery returns true if the text likely references stocks rather
// than being a pure crypto/general query. This avoids hitting the Sina search
// API on every single message (saves ~200ms latency + external API call).
func looksLikeStockQuery(text string) bool {
upper := strings.ToUpper(text)
// Check for known stock-related Chinese keywords
stockKeywords := []string{
"股", "A股", "港股", "美股", "股票", "涨停", "跌停", "大盘",
"沪指", "深指", "恒指", "纳指", "标普", "道琼斯",
"茅台", "比亚迪", "宁德", "腾讯", "阿里", "美团", "小米",
"京东", "百度", "苹果", "特斯拉", "英伟达", "微软", "谷歌",
"盘前", "盘后", "开盘", "收盘", "涨幅", "跌幅",
}
for _, kw := range stockKeywords {
if strings.Contains(text, kw) {
return true
}
}
// Check for US stock ticker patterns (1-5 uppercase letters not matching crypto)
for _, word := range strings.Fields(upper) {
word = strings.Trim(word, ".,!?;:()[]{}\"'")
if len(word) >= 1 && len(word) <= 5 {
allLetter := true
for _, c := range word {
if c < 'A' || c > 'Z' {
allLetter = false
break
}
}
if allLetter {
// Check if it's in the known US ticker map
if _, ok := usTickerMap[word]; ok {
return true
}
}
}
}
// Check for 6-digit A-share codes or 5-digit HK codes
for _, w := range strings.Fields(text) {
w = strings.TrimSpace(w)
if len(w) == 5 || len(w) == 6 {
if _, err := strconv.Atoi(w); err == nil {
return true
}
}
}
return false
}
func toFloat(v interface{}) float64 {
switch x := v.(type) {
case float64:
return x
case float32:
return float64(x)
case int:
return float64(x)
case int64:
return float64(x)
case int32:
return float64(x)
case string:
f, _ := strconv.ParseFloat(x, 64)
return f
case json.Number:
f, _ := x.Float64()
return f
}
return 0
}

View File

@@ -1,127 +0,0 @@
package agent
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"nofx/store"
)
func TestReadBackendLogEntriesReturnsRecentErrorLines(t *testing.T) {
wd, err := os.Getwd()
if err != nil {
t.Fatalf("Getwd() error = %v", err)
}
tmp := t.TempDir()
if err := os.Chdir(tmp); err != nil {
t.Fatalf("Chdir(tmp) error = %v", err)
}
t.Cleanup(func() {
_ = os.Chdir(wd)
})
if err := os.MkdirAll("data", 0o755); err != nil {
t.Fatalf("MkdirAll(data) error = %v", err)
}
logPath := filepath.Join("data", "nofx_2099-01-01.log")
content := strings.Join([]string{
"04-19 13:00:00 [INFO] api/server.go:590 API server starting",
"04-19 13:00:01 [ERRO] api/server.go:600 invalid signature for okx account",
"04-19 13:00:02 [ERRO] agent/tools.go:123 model update failed: missing api key",
}, "\n") + "\n"
if err := os.WriteFile(logPath, []byte(content), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
path, entries, err := readBackendLogEntries(10, "model", true)
if err != nil {
t.Fatalf("readBackendLogEntries() error = %v", err)
}
if !strings.Contains(path, "nofx_2099-01-01.log") {
t.Fatalf("unexpected log path: %s", path)
}
if len(entries) != 1 || !strings.Contains(entries[0], "missing api key") {
t.Fatalf("unexpected filtered entries: %#v", entries)
}
}
func TestToolGetBackendLogsRequiresOwnedTrader(t *testing.T) {
wd, err := os.Getwd()
if err != nil {
t.Fatalf("Getwd() error = %v", err)
}
tmp := t.TempDir()
if err := os.Chdir(tmp); err != nil {
t.Fatalf("Chdir(tmp) error = %v", err)
}
t.Cleanup(func() {
_ = os.Chdir(wd)
})
if err := os.MkdirAll("data", 0o755); err != nil {
t.Fatalf("MkdirAll(data) error = %v", err)
}
logPath := filepath.Join("data", "nofx_2099-01-01.log")
content := strings.Join([]string{
"04-19 13:00:00 [INFO] api/server.go:590 API server starting",
"04-19 13:00:01 [ERRO] trader/runtime.go:88 trader_id=trader-owned strategy execution failed",
"04-19 13:00:02 [ERRO] trader/runtime.go:89 trader_id=trader-other strategy execution failed",
}, "\n") + "\n"
if err := os.WriteFile(logPath, []byte(content), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
a := newTestAgentWithStore(t)
if err := a.store.Trader().Create(&store.Trader{
ID: "trader-owned",
UserID: "user-1",
Name: "Owned Trader",
AIModelID: "model-1",
ExchangeID: "exchange-1",
StrategyID: "strategy-1",
InitialBalance: 1000,
}); err != nil {
t.Fatalf("create owned trader: %v", err)
}
if err := a.store.Trader().Create(&store.Trader{
ID: "trader-other",
UserID: "user-2",
Name: "Other Trader",
AIModelID: "model-2",
ExchangeID: "exchange-2",
StrategyID: "strategy-2",
InitialBalance: 1000,
}); err != nil {
t.Fatalf("create other trader: %v", err)
}
resp := a.toolGetBackendLogs("user-1", `{"trader_id":"trader-owned","limit":5}`)
var okResult struct {
TraderID string `json:"trader_id"`
Entries []string `json:"entries"`
Count int `json:"count"`
}
if err := json.Unmarshal([]byte(resp), &okResult); err != nil {
t.Fatalf("unmarshal owned response: %v\nraw=%s", err, resp)
}
if okResult.TraderID != "trader-owned" || okResult.Count != 1 {
t.Fatalf("unexpected owned response: %+v", okResult)
}
if len(okResult.Entries) != 1 || !strings.Contains(okResult.Entries[0], "trader-owned") {
t.Fatalf("unexpected owned entries: %#v", okResult.Entries)
}
resp = a.toolGetBackendLogs("user-1", `{"trader_id":"trader-other","limit":5}`)
var denied struct {
Error string `json:"error"`
}
if err := json.Unmarshal([]byte(resp), &denied); err != nil {
t.Fatalf("unmarshal denied response: %v\nraw=%s", err, resp)
}
if denied.Error != "trader not found for current user" {
t.Fatalf("unexpected denied response: %+v", denied)
}
}

View File

@@ -1,184 +0,0 @@
package agent
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"nofx/safe"
"strings"
"sync"
"time"
)
// Brain handles proactive intelligence: signals, news, market briefs.
type Brain struct {
agent *Agent
logger *slog.Logger
http *http.Client
stopCh chan struct{}
stopOnce sync.Once
recentSignals sync.Map // debounce
}
func NewBrain(agent *Agent, logger *slog.Logger) *Brain {
return &Brain{
agent: agent,
logger: logger,
http: &http.Client{Timeout: 15 * time.Second},
stopCh: make(chan struct{}),
}
}
func (b *Brain) Stop() { b.stopOnce.Do(func() { close(b.stopCh) }) }
// cleanStaleSignals removes debounce entries older than 30 minutes.
func (b *Brain) cleanStaleSignals() {
cutoff := time.Now().Add(-30 * time.Minute)
b.recentSignals.Range(func(key, value any) bool {
if t, ok := value.(time.Time); ok && t.Before(cutoff) {
b.recentSignals.Delete(key)
}
return true
})
}
func (b *Brain) HandleSignal(sig Signal) {
key := fmt.Sprintf("%s:%s", sig.Type, sig.Symbol)
if v, ok := b.recentSignals.Load(key); ok {
if time.Since(v.(time.Time)) < 10*time.Minute {
return
}
}
b.recentSignals.Store(key, time.Now())
emoji := map[string]string{"info": "", "warning": "⚠️", "critical": "🚨"}
e := emoji[sig.Severity]
if e == "" { e = "📊" }
b.agent.notifyAll(fmt.Sprintf("%s *%s*\n\n%s", e, sig.Title, sig.Detail))
}
func (b *Brain) StartNewsScan(interval time.Duration) {
seen := make(map[string]bool)
safe.GoNamed("brain-news-scan", func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
cleanTick := 0
for {
select {
case <-b.stopCh: return
case <-ticker.C:
b.scanNews(seen)
cleanTick++
if cleanTick%6 == 0 { // every ~30 min
b.cleanStaleSignals()
}
}
}
})
}
func (b *Brain) scanNews(seen map[string]bool) {
resp, err := b.http.Get("https://min-api.cryptocompare.com/data/v2/news/?lang=EN&sortOrder=latest")
if err != nil { return }
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b.logger.Debug("news API non-200", "status", resp.StatusCode)
return
}
body, err := safe.ReadAllLimited(resp.Body, 1024*1024) // 1MB limit
if err != nil { return }
var result struct {
Data []struct {
Title string `json:"title"`
Source string `json:"source"`
URL string `json:"url"`
Body string `json:"body"`
Categories string `json:"categories"`
PublishedOn int64 `json:"published_on"`
} `json:"Data"`
}
if err := json.Unmarshal(body, &result); err != nil { return }
bullish := []string{"surge", "rally", "bullish", "breakout", "ath", "pump", "adoption"}
bearish := []string{"crash", "dump", "bearish", "sell-off", "plunge", "hack", "ban", "fraud"}
for _, d := range result.Data {
if seen[d.URL] { continue }
seen[d.URL] = true
if time.Since(time.Unix(d.PublishedOn, 0)) > 10*time.Minute { continue }
lower := strings.ToLower(d.Title + " " + d.Body)
bc, brc := 0, 0
for _, w := range bullish { if strings.Contains(lower, w) { bc++ } }
for _, w := range bearish { if strings.Contains(lower, w) { brc++ } }
if bc == 0 && brc == 0 { continue }
emoji := "📰"
sentiment := "NEUTRAL"
if bc > brc { emoji = "🟢"; sentiment = "BULLISH" }
if brc > bc { emoji = "🔴"; sentiment = "BEARISH" }
b.agent.notifyAll(fmt.Sprintf("%s *News*\n\n%s\n\n• Source: %s\n• Sentiment: %s",
emoji, d.Title, d.Source, sentiment))
}
// Evict ~half when seen map gets large (keep recent half to avoid re-notifying)
if len(seen) > 1000 {
i, half := 0, len(seen)/2
for k := range seen {
if i >= half { break }
delete(seen, k)
i++
}
}
}
func (b *Brain) StartMarketBriefs(hours []int) {
safe.GoNamed("brain-market-briefs", func() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
sent := make(map[string]bool)
for {
select {
case <-b.stopCh: return
case now := <-ticker.C:
key := now.Format("2006-01-02-15")
for _, h := range hours {
if now.Hour() == h && now.Minute() == 30 && !sent[key] {
sent[key] = true
b.sendBrief(h)
}
}
}
}
})
}
func (b *Brain) sendBrief(hour int) {
title := "☀️ *早间市场简报*"
if hour >= 18 { title = "🌙 *晚间市场简报*" }
// Fetch BTC/ETH prices for the brief
var btcPrice, ethPrice, btcChg, ethChg string
for _, sym := range []string{"BTCUSDT", "ETHUSDT"} {
resp, err := b.http.Get(fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", sym))
if err != nil { continue }
body, readErr := safe.ReadAllLimited(resp.Body, 64*1024) // 64KB limit
statusOK := resp.StatusCode == http.StatusOK
resp.Body.Close()
if readErr != nil || !statusOK { continue }
var t map[string]string
if err := json.Unmarshal(body, &t); err != nil { continue }
if sym == "BTCUSDT" { btcPrice = t["lastPrice"]; btcChg = t["priceChangePercent"] }
if sym == "ETHUSDT" { ethPrice = t["lastPrice"]; ethChg = t["priceChangePercent"] }
}
brief := fmt.Sprintf("%s\n\n• BTC: $%s (%s%%)\n• ETH: $%s (%s%%)\n\n_%s_",
title, btcPrice, btcChg, ethPrice, ethChg, time.Now().Format("2006-01-02 15:04"))
b.agent.notifyAll(brief)
}

View File

@@ -1,387 +0,0 @@
package agent
import (
"encoding/json"
"path/filepath"
"strings"
"testing"
"nofx/store"
)
func newTestAgentWithStore(t *testing.T) *Agent {
t.Helper()
st, err := store.New(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("create test store: %v", err)
}
t.Cleanup(func() {
_ = st.Close()
})
return &Agent{store: st}
}
func TestToolManageExchangeConfigLifecycle(t *testing.T) {
a := newTestAgentWithStore(t)
createResp := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"binance",
"account_name":"Main",
"enabled":true,
"testnet":true
}`)
var created struct {
Status string `json:"status"`
Action string `json:"action"`
Exchange safeExchangeToolConfig `json:"exchange"`
}
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
t.Fatalf("unmarshal create response: %v\nraw=%s", err, createResp)
}
if created.Status != "ok" || created.Action != "create" {
t.Fatalf("unexpected create response: %+v", created)
}
if created.Exchange.AccountName != "Main" || created.Exchange.ExchangeType != "binance" {
t.Fatalf("unexpected exchange payload: %+v", created.Exchange)
}
updateResp := a.toolManageExchangeConfig("user-1", `{
"action":"update",
"exchange_id":"`+created.Exchange.ID+`",
"account_name":"Renamed",
"enabled":false
}`)
var updated struct {
Status string `json:"status"`
Action string `json:"action"`
Exchange safeExchangeToolConfig `json:"exchange"`
}
if err := json.Unmarshal([]byte(updateResp), &updated); err != nil {
t.Fatalf("unmarshal update response: %v\nraw=%s", err, updateResp)
}
if updated.Exchange.AccountName != "Renamed" || updated.Exchange.Enabled {
t.Fatalf("unexpected updated exchange payload: %+v", updated.Exchange)
}
deleteResp := a.toolManageExchangeConfig("user-1", `{
"action":"delete",
"exchange_id":"`+created.Exchange.ID+`"
}`)
var deleted map[string]any
if err := json.Unmarshal([]byte(deleteResp), &deleted); err != nil {
t.Fatalf("unmarshal delete response: %v\nraw=%s", err, deleteResp)
}
if deleted["status"] != "ok" || deleted["action"] != "delete" {
t.Fatalf("unexpected delete response: %+v", deleted)
}
}
func TestToolManageModelConfigLifecycle(t *testing.T) {
a := newTestAgentWithStore(t)
createResp := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"openai",
"enabled":true,
"api_key":"sk-test",
"custom_api_url":"https://api.openai.com/v1",
"custom_model_name":"gpt-5-mini"
}`)
var created struct {
Status string `json:"status"`
Action string `json:"action"`
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
t.Fatalf("unmarshal create response: %v\nraw=%s", err, createResp)
}
if created.Status != "ok" || created.Action != "create" {
t.Fatalf("unexpected create response: %+v", created)
}
if created.Model.Provider != "openai" || created.Model.CustomModelName != "gpt-5-mini" {
t.Fatalf("unexpected model payload: %+v", created.Model)
}
updateResp := a.toolManageModelConfig("user-1", `{
"action":"update",
"model_id":"`+created.Model.ID+`",
"enabled":false,
"custom_model_name":"gpt-5"
}`)
var updated struct {
Status string `json:"status"`
Action string `json:"action"`
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(updateResp), &updated); err != nil {
t.Fatalf("unmarshal update response: %v\nraw=%s", err, updateResp)
}
if updated.Model.Enabled || updated.Model.CustomModelName != "gpt-5" {
t.Fatalf("unexpected updated model payload: %+v", updated.Model)
}
deleteResp := a.toolManageModelConfig("user-1", `{
"action":"delete",
"model_id":"`+created.Model.ID+`"
}`)
var deleted map[string]any
if err := json.Unmarshal([]byte(deleteResp), &deleted); err != nil {
t.Fatalf("unmarshal delete response: %v\nraw=%s", err, deleteResp)
}
if deleted["status"] != "ok" || deleted["action"] != "delete" {
t.Fatalf("unexpected delete response: %+v", deleted)
}
}
func TestToolManageModelConfigRejectsEnableWithoutAPIKey(t *testing.T) {
a := newTestAgentWithStore(t)
createResp := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"openai",
"enabled":false,
"custom_model_name":"gpt-4o"
}`)
var created struct {
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
t.Fatalf("unmarshal create response: %v\nraw=%s", err, createResp)
}
updateResp := a.toolManageModelConfig("user-1", `{
"action":"update",
"model_id":"`+created.Model.ID+`",
"enabled":true
}`)
if !strings.Contains(updateResp, "cannot enable model config before API key is configured") {
t.Fatalf("expected enabling incomplete model to fail, got %s", updateResp)
}
}
func TestGetDefaultSkipsEnabledModelWithoutAPIKey(t *testing.T) {
a := newTestAgentWithStore(t)
incompleteCreate := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"openai",
"enabled":true,
"custom_model_name":"gpt-4o"
}`)
var incomplete struct {
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(incompleteCreate), &incomplete); err != nil {
t.Fatalf("unmarshal incomplete create response: %v\nraw=%s", err, incompleteCreate)
}
completeCreate := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"deepseek",
"enabled":true,
"api_key":"sk-test",
"custom_model_name":"deepseek-chat"
}`)
var complete struct {
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(completeCreate), &complete); err != nil {
t.Fatalf("unmarshal complete create response: %v\nraw=%s", err, completeCreate)
}
model, err := a.store.AIModel().GetDefault("user-1")
if err != nil {
t.Fatalf("GetDefault() error = %v", err)
}
if model.ID != complete.Model.ID {
t.Fatalf("expected GetDefault to skip incomplete enabled model and return %s, got %s", complete.Model.ID, model.ID)
}
}
func TestToolManageTraderLifecycle(t *testing.T) {
a := newTestAgentWithStore(t)
modelResp := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"openai",
"enabled":true,
"api_key":"sk-test",
"custom_api_url":"https://api.openai.com/v1",
"custom_model_name":"gpt-5-mini"
}`)
var modelCreated struct {
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(modelResp), &modelCreated); err != nil {
t.Fatalf("unmarshal model response: %v", err)
}
exchangeResp := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"binance",
"account_name":"Main",
"enabled":true
}`)
var exchangeCreated struct {
Exchange safeExchangeToolConfig `json:"exchange"`
}
if err := json.Unmarshal([]byte(exchangeResp), &exchangeCreated); err != nil {
t.Fatalf("unmarshal exchange response: %v", err)
}
createResp := a.toolManageTrader("user-1", `{
"action":"create",
"name":"Momentum Trader",
"ai_model_id":"`+modelCreated.Model.ID+`",
"exchange_id":"`+exchangeCreated.Exchange.ID+`",
"scan_interval_minutes":5
}`)
var created struct {
Status string `json:"status"`
Action string `json:"action"`
Trader safeTraderToolConfig `json:"trader"`
}
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
t.Fatalf("unmarshal create trader response: %v\nraw=%s", err, createResp)
}
if created.Status != "ok" || created.Action != "create" {
t.Fatalf("unexpected create trader response: %+v", created)
}
if created.Trader.Name != "Momentum Trader" || created.Trader.ScanIntervalMinutes != 5 {
t.Fatalf("unexpected created trader: %+v", created.Trader)
}
listResp := a.toolManageTrader("user-1", `{"action":"list"}`)
var listed struct {
Count int `json:"count"`
Traders []safeTraderToolConfig `json:"traders"`
}
if err := json.Unmarshal([]byte(listResp), &listed); err != nil {
t.Fatalf("unmarshal list response: %v\nraw=%s", err, listResp)
}
if listed.Count != 1 || len(listed.Traders) != 1 {
t.Fatalf("unexpected trader list: %+v", listed)
}
updateResp := a.toolManageTrader("user-1", `{
"action":"update",
"trader_id":"`+created.Trader.ID+`",
"name":"Renamed Trader",
"scan_interval_minutes":8
}`)
var updated struct {
Status string `json:"status"`
Action string `json:"action"`
Trader safeTraderToolConfig `json:"trader"`
}
if err := json.Unmarshal([]byte(updateResp), &updated); err != nil {
t.Fatalf("unmarshal update trader response: %v\nraw=%s", err, updateResp)
}
if updated.Trader.Name != "Renamed Trader" || updated.Trader.ScanIntervalMinutes != 8 {
t.Fatalf("unexpected updated trader: %+v", updated.Trader)
}
deleteResp := a.toolManageTrader("user-1", `{
"action":"delete",
"trader_id":"`+created.Trader.ID+`"
}`)
var deleted map[string]any
if err := json.Unmarshal([]byte(deleteResp), &deleted); err != nil {
t.Fatalf("unmarshal delete trader response: %v\nraw=%s", err, deleteResp)
}
if deleted["status"] != "ok" || deleted["action"] != "delete" {
t.Fatalf("unexpected delete trader response: %+v", deleted)
}
}
func TestToolManageStrategyLifecycle(t *testing.T) {
a := newTestAgentWithStore(t)
createResp := a.toolManageStrategy("user-1", `{
"action":"create",
"name":"激进",
"description":"激进策略模板",
"lang":"zh"
}`)
var created struct {
Status string `json:"status"`
Action string `json:"action"`
Strategy safeStrategyToolConfig `json:"strategy"`
}
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
t.Fatalf("unmarshal create response: %v\nraw=%s", err, createResp)
}
if created.Status != "ok" || created.Action != "create" {
t.Fatalf("unexpected create response: %+v", created)
}
if created.Strategy.Name != "激进" {
t.Fatalf("unexpected strategy payload: %+v", created.Strategy)
}
listResp := a.toolGetStrategies("user-1")
if !strings.Contains(listResp, "激进") {
t.Fatalf("expected created strategy in list, got %s", listResp)
}
updateResp := a.toolManageStrategy("user-1", `{
"action":"update",
"strategy_id":"`+created.Strategy.ID+`",
"description":"更新后的描述"
}`)
var updated struct {
Status string `json:"status"`
Action string `json:"action"`
Strategy safeStrategyToolConfig `json:"strategy"`
}
if err := json.Unmarshal([]byte(updateResp), &updated); err != nil {
t.Fatalf("unmarshal update response: %v\nraw=%s", err, updateResp)
}
if updated.Strategy.Description != "更新后的描述" {
t.Fatalf("unexpected updated strategy payload: %+v", updated.Strategy)
}
activateResp := a.toolManageStrategy("user-1", `{
"action":"activate",
"strategy_id":"`+created.Strategy.ID+`"
}`)
if !strings.Contains(activateResp, `"action":"activate"`) {
t.Fatalf("unexpected activate response: %s", activateResp)
}
deleteResp := a.toolManageStrategy("user-1", `{
"action":"delete",
"strategy_id":"`+created.Strategy.ID+`"
}`)
if !strings.Contains(deleteResp, `"action":"delete"`) {
t.Fatalf("unexpected delete response: %s", deleteResp)
}
}
func TestLoadAIClientFromStoreUserUsesUserSpecificEnabledModel(t *testing.T) {
a := newTestAgentWithStore(t)
if err := a.store.AIModel().Update("user-42", "openai", true, "sk-test", "https://api.openai.com/v1", "gpt-5-mini"); err != nil {
t.Fatalf("seed model: %v", err)
}
client, modelName, ok := a.loadAIClientFromStoreUser("user-42")
if !ok {
t.Fatal("expected AI client to load from user-specific model")
}
if client == nil {
t.Fatal("expected non-nil AI client")
}
if modelName != "gpt-5-mini" {
t.Fatalf("unexpected model name: %s", modelName)
}
// After the provider registry refactor, registered providers (like openai)
// return their own AIClient implementation, not *mcp.Client.
if client == nil {
t.Fatal("expected non-nil AI client from provider registry")
}
}

View File

@@ -1,339 +0,0 @@
package agent
import (
"encoding/json"
"fmt"
"strings"
"time"
)
const (
executionStatusPlanning = "planning"
executionStatusRunning = "running"
executionStatusWaitingUser = "waiting_user"
executionStatusCompleted = "completed"
executionStatusFailed = "failed"
)
const (
planStepTypeTool = "tool"
planStepTypeReason = "reason"
planStepTypeAskUser = "ask_user"
planStepTypeRespond = "respond"
)
const (
planStepStatusPending = "pending"
planStepStatusRunning = "running"
planStepStatusCompleted = "completed"
planStepStatusFailed = "failed"
)
type ExecutionState struct {
SessionID string `json:"session_id"`
UserID int64 `json:"user_id"`
Goal string `json:"goal"`
Status string `json:"status"`
PlanID string `json:"plan_id"`
Steps []PlanStep `json:"steps,omitempty"`
CurrentStepID string `json:"current_step_id,omitempty"`
CurrentReferences *CurrentReferences `json:"current_references,omitempty"`
DynamicSnapshots []Observation `json:"dynamic_snapshots,omitempty"`
ExecutionLog []Observation `json:"execution_log,omitempty"`
SummaryNotes []Observation `json:"summary_notes,omitempty"`
Waiting *WaitingState `json:"waiting,omitempty"`
Observations []Observation `json:"observations,omitempty"`
FinalAnswer string `json:"final_answer,omitempty"`
LastError string `json:"last_error,omitempty"`
UpdatedAt string `json:"updated_at"`
}
type PlanStep struct {
ID string `json:"id"`
Type string `json:"type"`
Title string `json:"title,omitempty"`
Status string `json:"status,omitempty"`
ToolName string `json:"tool_name,omitempty"`
ToolArgs map[string]any `json:"tool_args,omitempty"`
Instruction string `json:"instruction,omitempty"`
RequiresConfirmation bool `json:"requires_confirmation,omitempty"`
OutputSummary string `json:"output_summary,omitempty"`
Error string `json:"error,omitempty"`
}
type Observation struct {
StepID string `json:"step_id,omitempty"`
Kind string `json:"kind"`
Summary string `json:"summary"`
RawJSON string `json:"raw_json,omitempty"`
CreatedAt string `json:"created_at"`
}
type WaitingState struct {
Question string `json:"question,omitempty"`
Intent string `json:"intent,omitempty"`
PendingFields []string `json:"pending_fields,omitempty"`
ConfirmationTarget string `json:"confirmation_target,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
}
type EntityReference struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
}
type CurrentReferences struct {
Strategy *EntityReference `json:"strategy,omitempty"`
Trader *EntityReference `json:"trader,omitempty"`
Model *EntityReference `json:"model,omitempty"`
Exchange *EntityReference `json:"exchange,omitempty"`
}
type executionPlan struct {
Goal string `json:"goal"`
Steps []PlanStep `json:"steps"`
}
const (
executionLogMaxEntries = 8
summaryNotesMaxEntries = 4
)
func ExecutionStateConfigKey(userID int64) string {
return fmt.Sprintf("agent_execution_state_%d", userID)
}
func (a *Agent) getExecutionState(userID int64) ExecutionState {
if a.store == nil {
return ExecutionState{}
}
raw, err := a.store.GetSystemConfig(ExecutionStateConfigKey(userID))
if err != nil {
a.logger.Warn("failed to load execution state", "error", err, "user_id", userID)
return ExecutionState{}
}
raw = strings.TrimSpace(raw)
if raw == "" {
return ExecutionState{}
}
var state ExecutionState
if err := json.Unmarshal([]byte(raw), &state); err != nil {
a.logger.Warn("failed to parse execution state", "error", err, "user_id", userID)
return ExecutionState{}
}
return normalizeExecutionState(state)
}
func (a *Agent) saveExecutionState(state ExecutionState) error {
if a.store == nil {
return fmt.Errorf("store unavailable")
}
state = normalizeExecutionState(state)
if state.SessionID == "" {
return a.store.SetSystemConfig(ExecutionStateConfigKey(state.UserID), "")
}
data, err := json.Marshal(state)
if err != nil {
return err
}
return a.store.SetSystemConfig(ExecutionStateConfigKey(state.UserID), string(data))
}
func (a *Agent) clearExecutionState(userID int64) {
if a.store == nil {
return
}
if err := a.store.SetSystemConfig(ExecutionStateConfigKey(userID), ""); err != nil {
a.logger.Warn("failed to clear execution state", "error", err, "user_id", userID)
}
}
func newExecutionState(userID int64, goal string) ExecutionState {
now := time.Now().UTC().Format(time.RFC3339)
return normalizeExecutionState(ExecutionState{
SessionID: fmt.Sprintf("sess_%d", time.Now().UTC().UnixNano()),
UserID: userID,
Goal: strings.TrimSpace(goal),
Status: executionStatusPlanning,
PlanID: fmt.Sprintf("plan_%d", time.Now().UTC().UnixNano()),
UpdatedAt: now,
})
}
func normalizeExecutionState(state ExecutionState) ExecutionState {
state.Goal = strings.TrimSpace(state.Goal)
state.Status = strings.TrimSpace(state.Status)
state.CurrentStepID = strings.TrimSpace(state.CurrentStepID)
state.FinalAnswer = strings.TrimSpace(state.FinalAnswer)
state.LastError = strings.TrimSpace(state.LastError)
state.CurrentReferences = normalizeCurrentReferences(state.CurrentReferences)
state.Waiting = normalizeWaitingState(state.Waiting)
if state.Status == "" && state.SessionID != "" {
state.Status = executionStatusPlanning
}
for i := range state.Steps {
state.Steps[i].ID = strings.TrimSpace(state.Steps[i].ID)
if state.Steps[i].ID == "" {
state.Steps[i].ID = fmt.Sprintf("step_%d", i+1)
}
state.Steps[i].Type = strings.TrimSpace(state.Steps[i].Type)
state.Steps[i].Title = strings.TrimSpace(state.Steps[i].Title)
state.Steps[i].ToolName = strings.TrimSpace(state.Steps[i].ToolName)
state.Steps[i].Instruction = strings.TrimSpace(state.Steps[i].Instruction)
state.Steps[i].OutputSummary = strings.TrimSpace(state.Steps[i].OutputSummary)
state.Steps[i].Error = strings.TrimSpace(state.Steps[i].Error)
if state.Steps[i].Status == "" {
state.Steps[i].Status = planStepStatusPending
}
}
if len(state.Observations) > 0 {
state.ExecutionLog = append(state.ExecutionLog, state.Observations...)
state.Observations = nil
}
state.DynamicSnapshots = normalizeObservationList(state.DynamicSnapshots)
state.ExecutionLog = normalizeObservationList(state.ExecutionLog)
state.SummaryNotes = normalizeObservationList(state.SummaryNotes)
state = compactExecutionLog(state)
if state.UpdatedAt == "" && state.SessionID != "" {
state.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
}
return state
}
func normalizeWaitingState(waiting *WaitingState) *WaitingState {
if waiting == nil {
return nil
}
waiting.Question = strings.TrimSpace(waiting.Question)
waiting.Intent = strings.TrimSpace(waiting.Intent)
waiting.PendingFields = cleanStringList(waiting.PendingFields)
waiting.ConfirmationTarget = strings.TrimSpace(waiting.ConfirmationTarget)
if waiting.CreatedAt == "" && (waiting.Question != "" || waiting.Intent != "" || len(waiting.PendingFields) > 0 || waiting.ConfirmationTarget != "") {
waiting.CreatedAt = time.Now().UTC().Format(time.RFC3339)
}
if waiting.Question == "" && waiting.Intent == "" && len(waiting.PendingFields) == 0 && waiting.ConfirmationTarget == "" {
return nil
}
return waiting
}
func normalizeEntityReference(ref *EntityReference) *EntityReference {
if ref == nil {
return nil
}
ref.ID = strings.TrimSpace(ref.ID)
ref.Name = strings.TrimSpace(ref.Name)
if ref.ID == "" && ref.Name == "" {
return nil
}
return ref
}
func normalizeCurrentReferences(refs *CurrentReferences) *CurrentReferences {
if refs == nil {
return nil
}
refs.Strategy = normalizeEntityReference(refs.Strategy)
refs.Trader = normalizeEntityReference(refs.Trader)
refs.Model = normalizeEntityReference(refs.Model)
refs.Exchange = normalizeEntityReference(refs.Exchange)
if refs.Strategy == nil && refs.Trader == nil && refs.Model == nil && refs.Exchange == nil {
return nil
}
return refs
}
func normalizeObservationList(values []Observation) []Observation {
if len(values) == 0 {
return nil
}
out := make([]Observation, 0, len(values))
for _, value := range values {
value.StepID = strings.TrimSpace(value.StepID)
value.Kind = strings.TrimSpace(value.Kind)
value.Summary = strings.TrimSpace(value.Summary)
value.RawJSON = strings.TrimSpace(value.RawJSON)
if value.Kind == "" && value.Summary == "" && value.RawJSON == "" {
continue
}
if value.CreatedAt == "" {
value.CreatedAt = time.Now().UTC().Format(time.RFC3339)
}
out = append(out, value)
}
if len(out) == 0 {
return nil
}
return out
}
func compactExecutionLog(state ExecutionState) ExecutionState {
if len(state.ExecutionLog) <= executionLogMaxEntries {
if len(state.SummaryNotes) > summaryNotesMaxEntries {
state.SummaryNotes = state.SummaryNotes[len(state.SummaryNotes)-summaryNotesMaxEntries:]
}
return state
}
overflow := state.ExecutionLog[:len(state.ExecutionLog)-executionLogMaxEntries]
state.ExecutionLog = state.ExecutionLog[len(state.ExecutionLog)-executionLogMaxEntries:]
summary := summarizeExecutionOverflow(overflow)
if summary != nil {
state.SummaryNotes = append(state.SummaryNotes, *summary)
if len(state.SummaryNotes) > summaryNotesMaxEntries {
state.SummaryNotes = state.SummaryNotes[len(state.SummaryNotes)-summaryNotesMaxEntries:]
}
}
return state
}
func summarizeExecutionOverflow(values []Observation) *Observation {
if len(values) == 0 {
return nil
}
summaries := make([]string, 0, len(values))
for _, value := range values {
label := value.Kind
if label == "" {
label = "observation"
}
if value.Summary != "" {
summaries = append(summaries, fmt.Sprintf("%s: %s", label, value.Summary))
} else if value.RawJSON != "" {
summaries = append(summaries, fmt.Sprintf("%s: %s", label, value.RawJSON))
}
}
if len(summaries) == 0 {
return nil
}
text := strings.Join(summaries, " | ")
if len(text) > 500 {
text = text[:500] + "..."
}
return &Observation{
Kind: "execution_summary",
Summary: text,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
}
}
func appendDynamicSnapshot(state *ExecutionState, obs Observation) {
state.DynamicSnapshots = append(state.DynamicSnapshots, obs)
state.DynamicSnapshots = normalizeObservationList(state.DynamicSnapshots)
}
func appendExecutionLog(state *ExecutionState, obs Observation) {
state.ExecutionLog = append(state.ExecutionLog, obs)
*state = normalizeExecutionState(*state)
}
func buildObservationContext(state ExecutionState) map[string]any {
state = normalizeExecutionState(state)
return map[string]any{
"current_references": state.CurrentReferences,
"dynamic_snapshots": state.DynamicSnapshots,
"execution_log": state.ExecutionLog,
"summary_notes": state.SummaryNotes,
}
}

View File

@@ -1,103 +0,0 @@
package agent
import (
"sync"
"time"
)
// chatMessage represents a single message in conversation history.
type chatMessage struct {
Role string `json:"role"` // "user" or "assistant"
Content string `json:"content"`
Timestamp time.Time `json:"timestamp"`
}
// chatHistory stores conversation history per user.
type chatHistory struct {
mu sync.RWMutex
sessions map[int64][]chatMessage
maxTurns int // hard safety cap in messages per user
}
func newChatHistory(maxTurns int) *chatHistory {
if maxTurns <= 0 {
maxTurns = 100 // default hard cap; recent-window trimming is handled separately
}
return &chatHistory{
sessions: make(map[int64][]chatMessage),
maxTurns: maxTurns,
}
}
// Add appends a message to the user's history.
func (h *chatHistory) Add(userID int64, role, content string) {
h.mu.Lock()
defer h.mu.Unlock()
h.sessions[userID] = append(h.sessions[userID], chatMessage{
Role: role,
Content: content,
Timestamp: time.Now(),
})
// Hard safety cap in case summarization is unavailable.
msgs := h.sessions[userID]
if len(msgs) > h.maxTurns {
h.sessions[userID] = msgs[len(msgs)-h.maxTurns:]
}
}
// Get returns the conversation history for a user.
func (h *chatHistory) Get(userID int64) []chatMessage {
h.mu.RLock()
defer h.mu.RUnlock()
msgs := h.sessions[userID]
if msgs == nil {
return nil
}
// Return a copy
result := make([]chatMessage, len(msgs))
copy(result, msgs)
return result
}
func (h *chatHistory) Replace(userID int64, msgs []chatMessage) {
h.mu.Lock()
defer h.mu.Unlock()
if len(msgs) == 0 {
delete(h.sessions, userID)
return
}
if len(msgs) > h.maxTurns {
msgs = msgs[len(msgs)-h.maxTurns:]
}
cloned := make([]chatMessage, len(msgs))
copy(cloned, msgs)
h.sessions[userID] = cloned
}
// Clear resets conversation history for a user.
func (h *chatHistory) Clear(userID int64) {
h.mu.Lock()
defer h.mu.Unlock()
delete(h.sessions, userID)
}
// CleanOld removes sessions older than the given duration.
func (h *chatHistory) CleanOld(maxAge time.Duration) {
h.mu.Lock()
defer h.mu.Unlock()
now := time.Now()
for uid, msgs := range h.sessions {
if len(msgs) > 0 {
lastMsg := msgs[len(msgs)-1]
if now.Sub(lastMsg.Timestamp) > maxAge {
delete(h.sessions, uid)
}
}
}
}

View File

@@ -1,86 +0,0 @@
package agent
var i18nMessages = map[string]map[string]string{
"help": {
"zh": "🤖 *NOFXi — 你的 AI 交易 Agent*\n\n" +
"*交易:* /buy /sell /long /short + 交易对 数量 杠杆\n" +
"*查询:* /positions /balance /pnl /traders\n" +
"*分析:* /analyze BTC\n" +
"*监控:* /watch BTC · /unwatch BTC\n" +
"*策略:* /strategy\n" +
"*系统:* /status /help\n\n" +
"直接跟我说话就行,中英文都可以 💬",
"en": "🤖 *NOFXi — Your AI Trading Agent*\n\n" +
"*Trade:* /buy /sell /long /short + symbol qty leverage\n" +
"*Query:* /positions /balance /pnl /traders\n" +
"*Analyze:* /analyze BTC\n" +
"*Monitor:* /watch BTC · /unwatch BTC\n" +
"*Strategy:* /strategy\n" +
"*System:* /status /help\n\n" +
"Just talk to me in any language 💬",
},
"status": {
"zh": "📊 *NOFXi 状态*\n\n• Traders: %d/%d 运行中\n• 监控: %d 个交易对\n• AI: %s\n• 时间: %s",
"en": "📊 *NOFXi Status*\n\n• Traders: %d/%d running\n• Watching: %d symbols\n• AI: %s\n• Time: %s",
},
"no_traders": {
"zh": "📭 暂无 Trader。请在 Web UI 中创建和配置。",
"en": "📭 No traders configured. Create one in Web UI.",
},
"no_running_trader": {
"zh": "⚠️ 没有运行中的 Trader。请在 Web UI 中启动。",
"en": "⚠️ No running trader. Start one in Web UI.",
},
"no_positions": {
"zh": "📭 当前没有持仓。",
"en": "📭 No open positions.",
},
"positions_header": {
"zh": "📊 *当前持仓*\n\n",
"en": "📊 *Open Positions*\n\n",
},
"total_pnl": {
"zh": "💰 *总未实现盈亏: $%.2f*",
"en": "💰 *Total Unrealized P/L: $%.2f*",
},
"balance_header": {
"zh": "💰 *账户余额*\n\n",
"en": "💰 *Account Balances*\n\n",
},
"traders_header": {
"zh": "🤖 *Traders*\n\n",
"en": "🤖 *Traders*\n\n",
},
"trade_usage": {
"zh": "用法: `/buy BTC 0.01` 或 `/sell ETH 0.5 3x`",
"en": "Usage: `/buy BTC 0.01` or `/sell ETH 0.5 3x`",
},
"invalid_qty": {
"zh": "❓ 无效数量: %s",
"en": "❓ Invalid quantity: %s",
},
"analysis_header": {
"zh": "🔍 *%s 市场分析*",
"en": "🔍 *%s Analysis*",
},
"sentinel_off": {
"zh": "⚠️ Sentinel 未启用。",
"en": "⚠️ Sentinel not enabled.",
},
"system_prompt": {
"zh": "你是 NOFXi一个专业的 AI 交易 Agent。简洁、专业、用中文回复。使用交易相关 emoji。",
"en": "You are NOFXi, a professional AI trading agent. Be concise, professional. Use trading emojis.",
},
}
func (a *Agent) msg(lang, key string) string {
if m, ok := i18nMessages[key]; ok {
if s, ok := m[lang]; ok {
return s
}
if s, ok := m["en"]; ok {
return s
}
}
return key
}

View File

@@ -1,344 +0,0 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"strings"
"nofx/mcp"
)
type llmSkillRouteDecision struct {
Route string `json:"route"`
Skill string `json:"skill,omitempty"`
Action string `json:"action,omitempty"`
Filter string `json:"filter,omitempty"`
}
func (a *Agent) tryLLMSkillRoute(ctx context.Context, storeUserID string, userID int64, lang, text string, onEvent func(event, data string)) (string, bool, error) {
if a.aiClient == nil {
return "", false, nil
}
text = strings.TrimSpace(text)
if text == "" {
return "", false, nil
}
recentConversationCtx := a.buildRecentConversationContext(userID, text)
taskStateCtx := buildTaskStateContext(a.getTaskState(userID))
executionState := normalizeExecutionState(a.getExecutionState(userID))
executionJSON, _ := json.Marshal(executionState)
systemPrompt := `You are the lightweight skill router for NOFXi.
Decide whether the user's message should go to a structured skill or continue to the planner.
Return JSON only. Do not return markdown.
Use route "skill" only when the user intent is clear enough to send directly to one structured skill.
Use route "planner" for ambiguous, multi-step, open-ended, analytical, or diagnostic requests.
Available skills:
- trader_management
- exchange_management
- model_management
- strategy_management
- trader_diagnosis
- exchange_diagnosis
- model_diagnosis
- strategy_diagnosis
For management skills, choose one atomic action from:
- query_list
- query_detail
- query_running
- create
- update_name
- update_bindings
- update_status
- update_endpoint
- update_config
- update_prompt
- delete
- start
- stop
- activate
- duplicate
Set filter only when it is clearly implied by the user. Use values like:
- running_only
- stopped_only
- enabled_only
- disabled_only
- active_only
- default_only
Rules:
- Prefer route "planner" when uncertain.
- Prefer route "planner" for market analysis, broad advice, multi-step troubleshooting, or requests that need synthesis.
- Prefer route "skill" for straightforward management requests like listing, creating, starting, stopping, enabling, disabling, renaming, or deleting known entities.
- Questions like "当前有运行中的trader吗" and "有没有 trader 在跑" are trader_management with action "query_running".
- Questions about one entity's details, config, parameters, or prompt should prefer action "query_detail".
- Do not use route "skill" for casual chat.
- Consider Recent conversation, Task state, and Execution state JSON before deciding.
Return JSON with this exact shape:
{"route":"skill|planner","skill":"","action":"","filter":""}`
userPrompt := fmt.Sprintf("Language: %s\nUser message: %s\n\nRecent conversation:\n%s\n\nTask state:\n%s\n\nExecution state JSON:\n%s", lang, text, recentConversationCtx, taskStateCtx, string(executionJSON))
stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout)
defer cancel()
raw, err := a.aiClient.CallWithRequest(&mcp.Request{
Messages: []mcp.Message{
mcp.NewSystemMessage(systemPrompt),
mcp.NewUserMessage(userPrompt),
},
Ctx: stageCtx,
})
if err != nil {
return "", false, nil
}
decision, err := parseLLMSkillRouteDecision(raw)
if err != nil || decision.Route != "skill" {
return "", false, nil
}
outcome, ok := a.executeLLMSkillRoute(storeUserID, userID, lang, text, decision)
if !ok {
return "", false, nil
}
review, err := a.reviewTaskCompletion(ctx, userID, lang, text, outcome)
if err != nil {
if outcome.Status == skillOutcomeRecoverableError || outcome.Status == skillOutcomeFatalError || outcome.Status == skillOutcomeNotHandled {
return "", false, nil
}
review = taskReviewDecision{Route: "complete", Answer: outcome.UserMessage}
}
if review.Route == "replan" {
answer, planErr := a.runPlannedAgent(ctx, storeUserID, userID, lang, fmt.Sprintf("Original user request:\n%s\n\nPrevious skill outcome JSON:\n%s", text, mustMarshalJSON(outcome)), onEvent)
return answer, true, planErr
}
answer := strings.TrimSpace(review.Answer)
if answer == "" {
answer = strings.TrimSpace(outcome.UserMessage)
}
if answer == "" {
return "", false, nil
}
a.recordSkillInteraction(userID, text, answer)
if onEvent != nil {
label := "llm_skill_route"
if decision.Skill != "" {
label += ":" + decision.Skill
}
if decision.Action != "" {
label += ":" + decision.Action
}
onEvent(StreamEventTool, label)
onEvent(StreamEventDelta, answer)
}
return answer, true, nil
}
func parseLLMSkillRouteDecision(raw string) (llmSkillRouteDecision, error) {
raw = strings.TrimSpace(raw)
raw = strings.TrimPrefix(raw, "```json")
raw = strings.TrimPrefix(raw, "```")
raw = strings.TrimSuffix(raw, "```")
raw = strings.TrimSpace(raw)
var decision llmSkillRouteDecision
if err := json.Unmarshal([]byte(raw), &decision); err == nil {
return normalizeLLMSkillRouteDecision(decision), nil
}
start := strings.Index(raw, "{")
end := strings.LastIndex(raw, "}")
if start >= 0 && end > start {
if err := json.Unmarshal([]byte(raw[start:end+1]), &decision); err == nil {
return normalizeLLMSkillRouteDecision(decision), nil
}
}
return llmSkillRouteDecision{}, fmt.Errorf("invalid llm skill route json")
}
func normalizeLLMSkillRouteDecision(decision llmSkillRouteDecision) llmSkillRouteDecision {
decision.Route = strings.TrimSpace(strings.ToLower(decision.Route))
decision.Skill = strings.TrimSpace(strings.ToLower(decision.Skill))
decision.Filter = strings.TrimSpace(strings.ToLower(decision.Filter))
if decision.Action == "query" && decision.Filter == "running_only" && decision.Skill == "trader_management" {
decision.Action = "query_running"
} else {
decision.Action = normalizeAtomicSkillAction(decision.Skill, decision.Action)
}
return decision
}
func (a *Agent) executeLLMSkillRoute(storeUserID string, userID int64, lang, text string, decision llmSkillRouteDecision) (skillOutcome, bool) {
session := skillSession{Name: decision.Skill, Action: decision.Action}
switch decision.Skill {
case "trader_management":
if decision.Action == "create" {
answer, handled := a.handleCreateTraderSkill(storeUserID, userID, lang, text, session)
if !handled {
return skillOutcome{}, false
}
return inferSkillOutcome(decision.Skill, decision.Action, answer, a.getSkillSession(userID), skillDataForAction(storeUserID, decision.Skill, decision.Action, a)), true
}
answer, handled := a.handleTraderManagementSkill(storeUserID, userID, lang, text, session)
if handled && decision.Action == "query_running" {
answer = applyTraderQueryFilter(lang, answer, a.toolListTraders(storeUserID), "running_only")
}
if !handled {
return skillOutcome{}, false
}
return inferSkillOutcome(decision.Skill, decision.Action, answer, a.getSkillSession(userID), skillDataForAction(storeUserID, decision.Skill, decision.Action, a)), true
case "exchange_management":
answer, handled := a.handleExchangeManagementSkill(storeUserID, userID, lang, text, session)
if !handled {
return skillOutcome{}, false
}
return inferSkillOutcome(decision.Skill, decision.Action, answer, a.getSkillSession(userID), skillDataForAction(storeUserID, decision.Skill, decision.Action, a)), true
case "model_management":
answer, handled := a.handleModelManagementSkill(storeUserID, userID, lang, text, session)
if !handled {
return skillOutcome{}, false
}
return inferSkillOutcome(decision.Skill, decision.Action, answer, a.getSkillSession(userID), skillDataForAction(storeUserID, decision.Skill, decision.Action, a)), true
case "strategy_management":
answer, handled := a.handleStrategyManagementSkill(storeUserID, userID, lang, text, session)
if !handled {
return skillOutcome{}, false
}
return inferSkillOutcome(decision.Skill, decision.Action, answer, a.getSkillSession(userID), skillDataForAction(storeUserID, decision.Skill, decision.Action, a)), true
case "model_diagnosis":
return skillOutcome{
Skill: decision.Skill,
Action: defaultIfEmpty(decision.Action, "diagnose"),
Status: skillOutcomeSuccess,
GoalAchieved: true,
UserMessage: a.handleModelDiagnosisSkill(storeUserID, lang, text),
}, true
case "exchange_diagnosis":
return skillOutcome{
Skill: decision.Skill,
Action: defaultIfEmpty(decision.Action, "diagnose"),
Status: skillOutcomeSuccess,
GoalAchieved: true,
UserMessage: a.handleExchangeDiagnosisSkill(storeUserID, lang, text),
}, true
case "trader_diagnosis":
return skillOutcome{
Skill: decision.Skill,
Action: defaultIfEmpty(decision.Action, "diagnose"),
Status: skillOutcomeSuccess,
GoalAchieved: true,
UserMessage: a.handleTraderDiagnosisSkill(storeUserID, lang, text),
}, true
case "strategy_diagnosis":
return skillOutcome{
Skill: decision.Skill,
Action: defaultIfEmpty(decision.Action, "diagnose"),
Status: skillOutcomeSuccess,
GoalAchieved: true,
UserMessage: a.handleStrategyDiagnosisSkill(storeUserID, lang, text),
}, true
default:
return skillOutcome{}, false
}
}
func skillDataForAction(storeUserID, skill, action string, a *Agent) map[string]any {
var raw string
switch skill {
case "trader_management":
if strings.HasPrefix(action, "query") {
raw = a.toolListTraders(storeUserID)
}
case "exchange_management":
if strings.HasPrefix(action, "query") {
raw = a.toolGetExchangeConfigs(storeUserID)
}
case "model_management":
if strings.HasPrefix(action, "query") {
raw = a.toolGetModelConfigs(storeUserID)
}
case "strategy_management":
if strings.HasPrefix(action, "query") {
raw = a.toolGetStrategies(storeUserID)
}
}
if strings.TrimSpace(raw) == "" {
return nil
}
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
return nil
}
return data
}
func mustMarshalJSON(v any) string {
data, _ := json.Marshal(v)
return string(data)
}
func applyTraderQueryFilter(lang, fallback, raw, filter string) string {
filter = strings.TrimSpace(strings.ToLower(filter))
if filter == "" {
return fallback
}
var payload struct {
Traders []struct {
Name string `json:"name"`
IsRunning bool `json:"is_running"`
} `json:"traders"`
}
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return fallback
}
switch filter {
case "running_only":
names := make([]string, 0, len(payload.Traders))
for _, trader := range payload.Traders {
if trader.IsRunning {
names = append(names, strings.TrimSpace(trader.Name))
}
}
if lang == "zh" {
if len(names) == 0 {
return "当前没有运行中的交易员。"
}
return fmt.Sprintf("当前有 %d 个运行中的交易员:%s。", len(names), strings.Join(names, "、"))
}
if len(names) == 0 {
return "There are no running traders right now."
}
return fmt.Sprintf("There are %d running traders right now: %s.", len(names), strings.Join(names, ", "))
case "stopped_only":
names := make([]string, 0, len(payload.Traders))
for _, trader := range payload.Traders {
if !trader.IsRunning {
names = append(names, strings.TrimSpace(trader.Name))
}
}
if lang == "zh" {
if len(names) == 0 {
return "当前没有已停止的交易员。"
}
return fmt.Sprintf("当前有 %d 个未运行的交易员:%s。", len(names), strings.Join(names, "、"))
}
if len(names) == 0 {
return "There are no stopped traders right now."
}
return fmt.Sprintf("There are %d stopped traders right now: %s.", len(names), strings.Join(names, ", "))
default:
return fallback
}
}

View File

@@ -1,467 +0,0 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"nofx/mcp"
)
const (
recentConversationRounds = 3
recentConversationMessages = recentConversationRounds * 2
taskStateSummaryTokenLimit = 1200
shortTermCompressThreshold = 900
incrementalTaskStateMessages = 6
incrementalTaskStateTokenLimit = 500
)
type DecisionMemory struct {
Action string `json:"action,omitempty"`
Reason string `json:"reason,omitempty"`
StillValid bool `json:"still_valid,omitempty"`
Timestamp string `json:"timestamp,omitempty"`
}
type TaskState struct {
CurrentGoal string `json:"current_goal,omitempty"`
ActiveFlow string `json:"active_flow,omitempty"`
// OpenLoops stores only high-level unresolved issues that still matter across turns.
// Step-level pending work belongs in ExecutionState, not here.
OpenLoops []string `json:"open_loops,omitempty"`
ImportantFacts []string `json:"important_facts,omitempty"`
LastDecision *DecisionMemory `json:"last_decision,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
func TaskStateConfigKey(userID int64) string {
return fmt.Sprintf("agent_task_state_%d", userID)
}
func (a *Agent) getTaskState(userID int64) TaskState {
if a.store == nil {
return TaskState{}
}
raw, err := a.store.GetSystemConfig(TaskStateConfigKey(userID))
if err != nil {
a.logger.Warn("failed to load task state", "error", err, "user_id", userID)
return TaskState{}
}
raw = strings.TrimSpace(raw)
if raw == "" {
return TaskState{}
}
var state TaskState
if err := json.Unmarshal([]byte(raw), &state); err != nil {
a.logger.Warn("failed to parse task state", "error", err, "user_id", userID)
return TaskState{}
}
return normalizeTaskState(state)
}
func (a *Agent) saveTaskState(userID int64, state TaskState) error {
if a.store == nil {
return fmt.Errorf("store unavailable")
}
state = normalizeTaskState(state)
if isZeroTaskState(state) {
return a.store.SetSystemConfig(TaskStateConfigKey(userID), "")
}
data, err := json.Marshal(state)
if err != nil {
return err
}
return a.store.SetSystemConfig(TaskStateConfigKey(userID), string(data))
}
func (a *Agent) clearTaskState(userID int64) {
if a.store == nil {
return
}
if err := a.store.SetSystemConfig(TaskStateConfigKey(userID), ""); err != nil {
a.logger.Warn("failed to clear task state", "error", err, "user_id", userID)
}
}
func normalizeTaskState(state TaskState) TaskState {
state.CurrentGoal = strings.TrimSpace(state.CurrentGoal)
state.ActiveFlow = strings.TrimSpace(state.ActiveFlow)
state.OpenLoops = filterTaskStateOpenLoops(cleanStringList(state.OpenLoops))
state.ImportantFacts = cleanStringList(state.ImportantFacts)
if state.LastDecision != nil {
state.LastDecision.Action = strings.TrimSpace(state.LastDecision.Action)
state.LastDecision.Reason = strings.TrimSpace(state.LastDecision.Reason)
state.LastDecision.Timestamp = strings.TrimSpace(state.LastDecision.Timestamp)
if state.LastDecision.Timestamp == "" && (state.LastDecision.Action != "" || state.LastDecision.Reason != "") {
state.LastDecision.Timestamp = time.Now().UTC().Format(time.RFC3339)
}
if state.LastDecision.Action == "" && state.LastDecision.Reason == "" {
state.LastDecision = nil
}
}
if state.UpdatedAt == "" && !isZeroTaskState(state) {
state.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
}
return state
}
func isZeroTaskState(state TaskState) bool {
return state.CurrentGoal == "" &&
state.ActiveFlow == "" &&
len(state.OpenLoops) == 0 &&
len(state.ImportantFacts) == 0 &&
state.LastDecision == nil
}
func cleanStringList(values []string) []string {
if len(values) == 0 {
return nil
}
out := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, v := range values {
v = strings.TrimSpace(v)
if v == "" {
continue
}
key := strings.ToLower(v)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, v)
}
if len(out) == 0 {
return nil
}
return out
}
func filterTaskStateOpenLoops(values []string) []string {
if len(values) == 0 {
return nil
}
rejectedPrefixes := []string{
"wait for ",
"waiting for ",
"ask for ",
"call ",
"run ",
"execute ",
"invoke ",
"use tool",
"step ",
}
rejectedContains := []string{
"current step",
"tool call",
"api key",
"api secret",
"secret key",
"passphrase",
"model id",
"exchange id",
}
filtered := make([]string, 0, len(values))
for _, value := range values {
lower := strings.ToLower(strings.TrimSpace(value))
if lower == "" {
continue
}
if matchesAnyPrefix(lower, rejectedPrefixes) || matchesAnyContains(lower, rejectedContains) {
continue
}
filtered = append(filtered, value)
}
if len(filtered) == 0 {
return nil
}
return filtered
}
func matchesAnyPrefix(value string, prefixes []string) bool {
for _, prefix := range prefixes {
if strings.HasPrefix(value, prefix) {
return true
}
}
return false
}
func matchesAnyContains(value string, patterns []string) bool {
for _, pattern := range patterns {
if strings.Contains(value, pattern) {
return true
}
}
return false
}
func buildTaskStateContext(state TaskState) string {
state = normalizeTaskState(state)
if isZeroTaskState(state) {
return ""
}
var sb strings.Builder
sb.WriteString("[Structured Task State - durable, non-derivable context]\n")
if state.CurrentGoal != "" {
sb.WriteString("- Current goal: ")
sb.WriteString(state.CurrentGoal)
sb.WriteString("\n")
}
if state.ActiveFlow != "" {
sb.WriteString("- Active flow: ")
sb.WriteString(state.ActiveFlow)
sb.WriteString("\n")
}
for _, loop := range state.OpenLoops {
sb.WriteString("- High-level open loop: ")
sb.WriteString(loop)
sb.WriteString("\n")
}
for _, fact := range state.ImportantFacts {
sb.WriteString("- Important fact: ")
sb.WriteString(fact)
sb.WriteString("\n")
}
if state.LastDecision != nil {
sb.WriteString("- Last decision: ")
sb.WriteString(state.LastDecision.Action)
if state.LastDecision.Reason != "" {
sb.WriteString(" | reason: ")
sb.WriteString(state.LastDecision.Reason)
}
if state.LastDecision.StillValid {
sb.WriteString(" | still valid")
}
sb.WriteString("\n")
}
return strings.TrimSpace(sb.String())
}
func estimateChatMessagesTokens(msgs []chatMessage) int {
total := 0
for _, msg := range msgs {
total += len([]rune(msg.Content))/3 + 10
}
return total
}
func formatChatMessagesForSummary(msgs []chatMessage) string {
var sb strings.Builder
for _, msg := range msgs {
if strings.TrimSpace(msg.Content) == "" {
continue
}
role := "User"
if msg.Role == "assistant" {
role = "Assistant"
}
sb.WriteString(role)
sb.WriteString(": ")
sb.WriteString(msg.Content)
sb.WriteString("\n")
}
return strings.TrimSpace(sb.String())
}
func (a *Agent) maybeCompressHistory(ctx context.Context, userID int64) {
if a.aiClient == nil || a.history == nil {
return
}
msgs := a.history.Get(userID)
if len(msgs) <= recentConversationMessages {
return
}
if estimateChatMessagesTokens(msgs) <= shortTermCompressThreshold {
return
}
splitAt := len(msgs) - recentConversationMessages
if splitAt <= 0 {
return
}
oldPart := msgs[:splitAt]
recentPart := msgs[splitAt:]
existingState := a.getTaskState(userID)
updatedState, err := a.summarizeConversationToTaskState(ctx, userID, existingState, oldPart)
if err != nil {
a.logger.Warn("failed to compress chat history", "error", err, "user_id", userID)
return
}
if err := a.saveTaskState(userID, updatedState); err != nil {
a.log().Warn("failed to persist task state", "error", err, "user_id", userID)
return
}
a.history.Replace(userID, recentPart)
}
func (a *Agent) maybeUpdateTaskStateIncrementally(ctx context.Context, userID int64) {
if a.aiClient == nil || a.history == nil {
return
}
msgs := a.history.Get(userID)
if len(msgs) < 2 {
return
}
window := msgs
if len(window) > incrementalTaskStateMessages {
window = window[len(window)-incrementalTaskStateMessages:]
}
existingState := a.getTaskState(userID)
updatedState, err := a.summarizeRecentConversationToTaskState(ctx, userID, existingState, window)
if err != nil {
a.log().Warn("failed to incrementally update task state", "error", err, "user_id", userID)
return
}
if err := a.saveTaskState(userID, updatedState); err != nil {
a.log().Warn("failed to persist incremental task state", "error", err, "user_id", userID)
}
}
func (a *Agent) summarizeConversationToTaskState(ctx context.Context, userID int64, existing TaskState, oldPart []chatMessage) (TaskState, error) {
transcript := formatChatMessagesForSummary(oldPart)
if transcript == "" {
return normalizeTaskState(existing), nil
}
existingJSON, err := json.Marshal(normalizeTaskState(existing))
if err != nil {
return TaskState{}, err
}
systemPrompt := `You maintain structured task state for a trading assistant.
Update the task state using the existing state plus archived dialogue.
Return JSON only. Do not return markdown.
Rules:
- Keep only durable, non-derivable context useful for future turns.
- Do not store market prices, balances, positions, or anything tools can fetch again.
- Do not store chit-chat or repeated wording.
- current_goal: the user's active objective, if any.
- active_flow: a named flow such as onboarding, trading_confirmation, market_analysis, or empty.
- open_loops: only high-level unresolved issues that still matter across turns.
- Do not put execution-step pending work into open_loops.
- Bad open_loops examples: "wait for API secret", "call get_exchange_configs", "run step 2", "ask user for exchange_id".
- Good open_loops examples: "finish trader setup after external configuration is ready", "user still wants to complete onboarding".
- important_facts: non-derivable facts worth remembering briefly.
- last_decision: keep only one current relevant decision; omit if none.
- Replace stale items instead of appending blindly.
- If a field is no longer relevant, return it empty or omit it.
- Never invent facts.`
userPrompt := fmt.Sprintf("Existing task state JSON:\n%s\n\nArchived dialogue to compress:\n%s\n\nReturn the new task state JSON with this exact shape:\n{\"current_goal\":\"\",\"active_flow\":\"\",\"open_loops\":[],\"important_facts\":[],\"last_decision\":{\"action\":\"\",\"reason\":\"\",\"still_valid\":false,\"timestamp\":\"\"},\"updated_at\":\"\"}", string(existingJSON), transcript)
req := &mcp.Request{
Messages: []mcp.Message{
mcp.NewSystemMessage(systemPrompt),
mcp.NewUserMessage(userPrompt),
},
Ctx: ctx,
MaxTokens: intPtr(taskStateSummaryTokenLimit),
}
resp, err := a.aiClient.CallWithRequest(req)
if err != nil {
return TaskState{}, err
}
state, err := parseTaskStateJSON(resp)
if err != nil {
return TaskState{}, err
}
state = normalizeTaskState(state)
a.log().Info("compressed chat history into task state", "user_id", userID, "archived_messages", len(oldPart))
return state, nil
}
func (a *Agent) summarizeRecentConversationToTaskState(ctx context.Context, userID int64, existing TaskState, recentPart []chatMessage) (TaskState, error) {
transcript := formatChatMessagesForSummary(recentPart)
if transcript == "" {
return normalizeTaskState(existing), nil
}
existingJSON, err := json.Marshal(normalizeTaskState(existing))
if err != nil {
return TaskState{}, err
}
systemPrompt := `You maintain structured task state for a trading assistant.
Update the task state incrementally using the existing state plus the latest conversation window.
Return JSON only. Do not return markdown.
Rules:
- Capture newly confirmed facts from the latest few turns immediately.
- Preserve important existing facts that still matter; replace stale items when contradicted.
- Keep only durable, non-derivable context useful for the next turns.
- current_goal: the user's active objective right now.
- active_flow: a named flow such as onboarding, trading_confirmation, market_analysis, strategy_debugging, or empty.
- open_loops: only high-level unresolved issues that still matter across turns.
- important_facts: include recently confirmed concrete facts, such as the current trader under discussion, the reported runtime error, the user's claimed config value, or the environment where the issue occurs.
- Do not store execution-step pending work or tool instructions.
- Do not store market prices, balances, or anything tools can fetch again.
- Keep last_decision only if there is a current relevant decision; omit it otherwise.
- Never invent facts.`
userPrompt := fmt.Sprintf("Existing task state JSON:\n%s\n\nLatest conversation window:\n%s\n\nReturn the updated task state JSON with this exact shape:\n{\"current_goal\":\"\",\"active_flow\":\"\",\"open_loops\":[],\"important_facts\":[],\"last_decision\":{\"action\":\"\",\"reason\":\"\",\"still_valid\":false,\"timestamp\":\"\"},\"updated_at\":\"\"}", string(existingJSON), transcript)
req := &mcp.Request{
Messages: []mcp.Message{
mcp.NewSystemMessage(systemPrompt),
mcp.NewUserMessage(userPrompt),
},
Ctx: ctx,
MaxTokens: intPtr(incrementalTaskStateTokenLimit),
}
resp, err := a.aiClient.CallWithRequest(req)
if err != nil {
return TaskState{}, err
}
state, err := parseTaskStateJSON(resp)
if err != nil {
return TaskState{}, err
}
state = normalizeTaskState(state)
a.log().Info("incrementally refreshed task state", "user_id", userID, "window_messages", len(recentPart))
return state, nil
}
func parseTaskStateJSON(raw string) (TaskState, error) {
raw = strings.TrimSpace(raw)
raw = strings.TrimPrefix(raw, "```json")
raw = strings.TrimPrefix(raw, "```")
raw = strings.TrimSuffix(raw, "```")
raw = strings.TrimSpace(raw)
var state TaskState
if err := json.Unmarshal([]byte(raw), &state); err == nil {
return state, nil
}
start := strings.Index(raw, "{")
end := strings.LastIndex(raw, "}")
if start >= 0 && end > start {
if err := json.Unmarshal([]byte(raw[start:end+1]), &state); err == nil {
return state, nil
}
}
return TaskState{}, fmt.Errorf("invalid task state json")
}
func intPtr(v int) *int {
return &v
}

View File

@@ -1,132 +0,0 @@
package agent
import (
"context"
"log/slog"
"path/filepath"
"strings"
"testing"
"time"
"nofx/mcp"
"nofx/store"
)
type fakeAIClient struct {
callCount int
}
func (f *fakeAIClient) SetAPIKey(string, string, string) {}
func (f *fakeAIClient) SetTimeout(time.Duration) {}
func (f *fakeAIClient) CallWithMessages(string, string) (string, error) {
return "", nil
}
func (f *fakeAIClient) CallWithRequest(req *mcp.Request) (string, error) {
f.callCount++
return `{"current_goal":"continue setup","active_flow":"onboarding","open_loops":["finish trader setup after external exchange/model configuration is ready"],"important_facts":["user selected OKX"],"last_decision":{"action":"paused setup","reason":"user asked a market question","still_valid":true},"updated_at":"2026-04-01T00:00:00Z"}`, nil
}
func (f *fakeAIClient) CallWithRequestStream(req *mcp.Request, onChunk func(string)) (string, error) {
return "", nil
}
func (f *fakeAIClient) CallWithRequestFull(req *mcp.Request) (*mcp.LLMResponse, error) {
return nil, nil
}
func TestMaybeCompressHistoryKeepsRecentThreeRounds(t *testing.T) {
st, err := store.New(filepath.Join(t.TempDir(), "nofxi-test.db"))
if err != nil {
t.Fatalf("store.New() error = %v", err)
}
fakeClient := &fakeAIClient{}
a := &Agent{
store: st,
logger: slog.Default(),
history: newChatHistory(100),
aiClient: fakeClient,
}
userID := int64(42)
payload := strings.Repeat("BTC ETH market context ", 20)
for i := 0; i < 6; i++ {
a.history.Add(userID, "user", "user turn #"+string(rune('0'+i))+" "+payload)
a.history.Add(userID, "assistant", "assistant turn #"+string(rune('0'+i))+" "+payload)
}
a.maybeCompressHistory(context.Background(), userID)
msgs := a.history.Get(userID)
if len(msgs) != recentConversationMessages {
t.Fatalf("expected %d recent messages, got %d", recentConversationMessages, len(msgs))
}
if fakeClient.callCount != 1 {
t.Fatalf("expected summarizer to be called once, got %d", fakeClient.callCount)
}
state := a.getTaskState(userID)
if state.CurrentGoal != "continue setup" {
t.Fatalf("expected persisted task state goal, got %#v", state)
}
if state.LastDecision == nil || state.LastDecision.Action != "paused setup" {
t.Fatalf("expected persisted last_decision, got %#v", state.LastDecision)
}
if len(state.OpenLoops) != 1 || state.OpenLoops[0] != "finish trader setup after external exchange/model configuration is ready" {
t.Fatalf("expected high-level open loop, got %#v", state.OpenLoops)
}
if strings.Contains(msgs[0].Content, "#0") {
t.Fatalf("expected oldest round to be compressed away, first recent message = %q", msgs[0].Content)
}
if !strings.Contains(msgs[0].Content, "#3") {
t.Fatalf("expected recent window to start from round #3, got %q", msgs[0].Content)
}
if !strings.Contains(msgs[len(msgs)-1].Content, "#5") {
t.Fatalf("expected latest round to remain in short-term history, got %q", msgs[len(msgs)-1].Content)
}
}
func TestNormalizeTaskStateDropsExecutionLevelOpenLoops(t *testing.T) {
state := normalizeTaskState(TaskState{
OpenLoops: []string{
"wait for API secret",
"call get_exchange_configs",
"finish trader setup after external configuration is ready",
},
})
if len(state.OpenLoops) != 1 {
t.Fatalf("expected only one high-level open loop to remain, got %#v", state.OpenLoops)
}
if state.OpenLoops[0] != "finish trader setup after external configuration is ready" {
t.Fatalf("unexpected open loop after normalization: %#v", state.OpenLoops)
}
}
func TestMaybeUpdateTaskStateIncrementallyPersistsShortConversationFacts(t *testing.T) {
st, err := store.New(filepath.Join(t.TempDir(), "nofxi-test.db"))
if err != nil {
t.Fatalf("store.New() error = %v", err)
}
fakeClient := &fakeAIClient{}
a := &Agent{
store: st,
logger: slog.Default(),
history: newChatHistory(100),
aiClient: fakeClient,
}
userID := int64(7)
a.history.Add(userID, "user", "我是在运行测试1交易员时遇到的错误是运行时出现的")
a.history.Add(userID, "assistant", "我会继续排查测试1交易员的运行时错误")
a.maybeUpdateTaskStateIncrementally(context.Background(), userID)
if fakeClient.callCount != 1 {
t.Fatalf("expected incremental summarizer to be called once, got %d", fakeClient.callCount)
}
state := a.getTaskState(userID)
if state.CurrentGoal != "continue setup" {
t.Fatalf("expected incrementally persisted task state, got %#v", state)
}
}

View File

@@ -1,606 +0,0 @@
package agent
import (
"fmt"
"strings"
"time"
"golang.org/x/text/cases"
"golang.org/x/text/language"
"nofx/store"
)
var titleCaser = cases.Title(language.English)
const setupExchangeAccountName = "Default"
// Onboard handles first-time setup through natural language.
// When there's no trader configured, the agent guides the user.
// SetupState tracks where the user is in the setup flow.
type SetupState struct {
Step string // "", "await_exchange", "await_api_key", "await_api_secret", "await_passphrase", "await_ai_model", "await_ai_key"
Exchange string
ExchangeID string
APIKey string
APISecret string
Passphrase string
AIProvider string
AIModel string
AIModelID string
AIKey string
AIBaseURL string
}
// needsSetup returns true if no traders are configured.
func (a *Agent) needsSetup() bool {
if a.traderManager == nil {
return true
}
return len(a.traderManager.GetAllTraders()) == 0
}
// getSetupState loads the current setup state from user preferences.
func (a *Agent) getSetupState(userID int64) *SetupState {
step, _ := a.store.GetSystemConfig(fmt.Sprintf("setup_step_%d", userID))
if step == "" {
return &SetupState{}
}
return &SetupState{
Step: step,
Exchange: getConfig(a.store, userID, "exchange"),
ExchangeID: getConfig(a.store, userID, "exchange_id"),
APIKey: getConfig(a.store, userID, "api_key"),
APISecret: getConfig(a.store, userID, "api_secret"),
Passphrase: getConfig(a.store, userID, "passphrase"),
AIProvider: getConfig(a.store, userID, "ai_provider"),
AIModel: getConfig(a.store, userID, "ai_model"),
AIModelID: getConfig(a.store, userID, "ai_model_id"),
AIKey: getConfig(a.store, userID, "ai_key"),
AIBaseURL: getConfig(a.store, userID, "ai_base_url"),
}
}
func (a *Agent) saveSetupState(userID int64, s *SetupState) {
a.store.SetSystemConfig(fmt.Sprintf("setup_step_%d", userID), s.Step)
setConfig(a.store, userID, "exchange", s.Exchange)
setConfig(a.store, userID, "exchange_id", s.ExchangeID)
// Store only a masked marker for secrets — full values stay in memory only.
// This prevents plaintext credentials from lingering in the config store
// if the setup flow is interrupted before clearSetupState runs.
if s.APIKey != "" {
setConfig(a.store, userID, "api_key", "****")
}
if s.APISecret != "" {
setConfig(a.store, userID, "api_secret", "****")
}
if s.Passphrase != "" {
setConfig(a.store, userID, "passphrase", "****")
}
setConfig(a.store, userID, "ai_provider", s.AIProvider)
setConfig(a.store, userID, "ai_model", s.AIModel)
setConfig(a.store, userID, "ai_model_id", s.AIModelID)
if s.AIKey != "" {
setConfig(a.store, userID, "ai_key", "****")
}
setConfig(a.store, userID, "ai_base_url", s.AIBaseURL)
}
func (a *Agent) clearSetupState(userID int64) {
for _, k := range []string{"step", "exchange", "exchange_id", "api_key", "api_secret", "passphrase", "ai_provider", "ai_model", "ai_model_id", "ai_key", "ai_base_url"} {
if err := a.store.SetSystemConfig(fmt.Sprintf("setup_%s_%d", k, userID), ""); err != nil {
a.log().Warn("clearSetupState: failed to clear key", "key", k, "error", err)
}
}
}
func getConfig(st *store.Store, uid int64, key string) string {
v, _ := st.GetSystemConfig(fmt.Sprintf("setup_%s_%d", key, uid))
return v
}
func setConfig(st *store.Store, uid int64, key, val string) {
st.SetSystemConfig(fmt.Sprintf("setup_%s_%d", key, uid), val)
}
// handleSetupFlow processes the setup conversation.
// Returns (response, handled). If handled=false, continue to normal routing.
func (a *Agent) handleSetupFlow(userID int64, text string, L string) (string, bool) {
return a.handleSetupFlowForStoreUser("default", userID, text, L)
}
func (a *Agent) handleSetupFlowForStoreUser(storeUserID string, userID int64, text string, L string) (string, bool) {
state := a.getSetupState(userID)
lower := strings.ToLower(text)
// Cancel setup — explicit or implicit (user asking unrelated questions)
if lower == "cancel" || lower == "取消" || lower == "/cancel" {
a.clearSetupState(userID)
return a.setupMsg(L, "cancelled"), true
}
// If in a step that expects a key/secret, check if user is NOT sending a key
// Keys are typically long strings without spaces and Chinese characters
if state.Step == "await_api_key" || state.Step == "await_api_secret" || state.Step == "await_passphrase" || state.Step == "await_ai_key" {
trimmed := strings.TrimSpace(text)
hasChinese := false
for _, r := range trimmed {
if r >= 0x4e00 && r <= 0x9fff {
hasChinese = true
break
}
}
hasSpaces := strings.Contains(trimmed, " ") && !strings.HasPrefix(trimmed, "sk-")
tooShort := len(trimmed) < 8
if hasChinese || hasSpaces || tooShort {
// User is probably asking a question, not providing a key
a.clearSetupState(userID)
if L == "zh" {
return "👌 配置已暂停。我先回答你的问题——\n\n随时发送 *开始配置* 继续配置。", false
}
return "👌 Setup paused. Let me answer your question first—\n\nSend *setup* anytime to continue.", false
}
}
switch state.Step {
case "await_exchange":
return a.handleExchangeChoice(userID, text, state, L)
case "await_api_key":
state.APIKey = strings.TrimSpace(text)
state.Step = "await_api_secret"
a.saveSetupState(userID, state)
return a.setupMsg(L, "ask_secret"), true
case "await_api_secret":
state.APISecret = strings.TrimSpace(text)
// OKX/Bitget/KuCoin need passphrase
if needsPassphrase(state.Exchange) {
state.Step = "await_passphrase"
a.saveSetupState(userID, state)
return a.setupMsg(L, "ask_passphrase"), true
}
exchangeID, err := a.saveSetupExchange(storeUserID, state)
if err != nil {
a.logger.Error("save exchange from setup failed", "error", err, "exchange", state.Exchange, "store_user_id", storeUserID)
if L == "zh" {
return fmt.Sprintf("⚠️ 交易所配置保存失败: %v\n请再试一次或稍后去 Web UI 继续。", err), true
}
return fmt.Sprintf("⚠️ Failed to save exchange config: %v\nPlease try again, or continue later in the Web UI.", err), true
}
state.ExchangeID = exchangeID
state.Step = "await_ai_model"
a.saveSetupState(userID, state)
if L == "zh" {
return "✅ 交易所配置已保存,在配置页里现在就能看到。\n\n" + a.setupMsg(L, "ask_ai"), true
}
return "✅ Exchange config saved. It should now be visible in the config page.\n\n" + a.setupMsg(L, "ask_ai"), true
case "await_passphrase":
state.Passphrase = strings.TrimSpace(text)
exchangeID, err := a.saveSetupExchange(storeUserID, state)
if err != nil {
a.logger.Error("save exchange from setup failed", "error", err, "exchange", state.Exchange, "store_user_id", storeUserID)
if L == "zh" {
return fmt.Sprintf("⚠️ 交易所配置保存失败: %v\n请再试一次或稍后去 Web UI 继续。", err), true
}
return fmt.Sprintf("⚠️ Failed to save exchange config: %v\nPlease try again, or continue later in the Web UI.", err), true
}
state.ExchangeID = exchangeID
state.Step = "await_ai_model"
a.saveSetupState(userID, state)
if L == "zh" {
return "✅ 交易所配置已保存,在配置页里现在就能看到。\n\n" + a.setupMsg(L, "ask_ai"), true
}
return "✅ Exchange config saved. It should now be visible in the config page.\n\n" + a.setupMsg(L, "ask_ai"), true
case "await_ai_model":
return a.handleAIChoice(storeUserID, userID, text, state, L)
case "await_ai_key":
state.AIKey = strings.TrimSpace(text)
aiModelID, err := a.saveSetupAIModel(storeUserID, state)
if err != nil {
a.logger.Error("save AI model from setup failed", "error", err, "provider", state.AIProvider, "store_user_id", storeUserID)
if L == "zh" {
return fmt.Sprintf("⚠️ AI 模型配置保存失败: %v\n请再试一次或稍后去 Web UI 继续。", err), true
}
return fmt.Sprintf("⚠️ Failed to save AI model config: %v\nPlease try again, or continue later in the Web UI.", err), true
}
state.AIModelID = aiModelID
return a.finishSetup(storeUserID, userID, state, L)
}
// Not in setup flow — only enter setup for a tiny set of explicit commands.
// Natural-language configuration requests should go to the planner first,
// including phrases like "开始配置" or "帮我配置交易所".
if isDirectSetupCommand(lower) {
state.Step = "await_exchange"
a.saveSetupState(userID, state)
return a.setupMsg(L, "ask_exchange"), true
}
// Everything else — let normal routing handle it
return "", false
}
func isDirectSetupCommand(text string) bool {
text = strings.ToLower(strings.TrimSpace(text))
if text == "" {
return false
}
switch text {
case "setup", "/setup", "开始配置", "配置", "开始设置":
return true
default:
return false
}
}
func (a *Agent) handleExchangeChoice(userID int64, text string, state *SetupState, L string) (string, bool) {
lower := strings.ToLower(strings.TrimSpace(text))
exchanges := map[string]string{
"binance": "binance", "币安": "binance", "1": "binance",
"okx": "okx", "欧易": "okx", "2": "okx",
"bybit": "bybit", "3": "bybit",
"bitget": "bitget", "4": "bitget",
"gate": "gate", "5": "gate",
"kucoin": "kucoin", "库币": "kucoin", "6": "kucoin",
"hyperliquid": "hyperliquid", "7": "hyperliquid",
}
ex, ok := exchanges[lower]
if !ok {
return a.setupMsg(L, "invalid_exchange"), true
}
state.Exchange = ex
state.Step = "await_api_key"
a.saveSetupState(userID, state)
if L == "zh" {
return fmt.Sprintf("✅ 选择了 *%s*\n\n请发送你的 API Key", titleCaser.String(ex)), true
}
return fmt.Sprintf("✅ Selected *%s*\n\nPlease send your API Key:", titleCaser.String(ex)), true
}
func (a *Agent) handleAIChoice(storeUserID string, userID int64, text string, state *SetupState, L string) (string, bool) {
lower := strings.ToLower(strings.TrimSpace(text))
models := map[string]struct{ provider, model, url string }{
"deepseek": {"deepseek", "deepseek-chat", "https://api.deepseek.com/v1"},
"1": {"deepseek", "deepseek-chat", "https://api.deepseek.com/v1"},
"qwen": {"qwen", "qwen-plus", "https://dashscope.aliyuncs.com/compatible-mode/v1"},
"通义": {"qwen", "qwen-plus", "https://dashscope.aliyuncs.com/compatible-mode/v1"},
"2": {"qwen", "qwen-plus", "https://dashscope.aliyuncs.com/compatible-mode/v1"},
"openai": {"openai", "gpt-4o", "https://api.openai.com/v1"},
"gpt": {"openai", "gpt-4o", "https://api.openai.com/v1"},
"3": {"openai", "gpt-4o", "https://api.openai.com/v1"},
"claude": {"claude", "claude-3-5-sonnet-20241022", "https://api.anthropic.com/v1"},
"4": {"claude", "claude-3-5-sonnet-20241022", "https://api.anthropic.com/v1"},
"skip": {"", "", ""},
"跳过": {"", "", ""},
"5": {"", "", ""},
}
choice, ok := models[lower]
if !ok {
return a.setupMsg(L, "invalid_ai"), true
}
if choice.model == "" {
// Skip AI, just create trader with exchange
state.AIProvider = ""
state.AIModel = ""
state.AIModelID = ""
state.AIKey = ""
return a.finishSetup(storeUserID, userID, state, L)
}
state.AIProvider = choice.provider
state.AIModel = choice.model
state.AIBaseURL = choice.url
state.Step = "await_ai_key"
a.saveSetupState(userID, state)
if L == "zh" {
return fmt.Sprintf("✅ AI 模型: *%s*\n\n请发送你的 API Key", choice.model), true
}
return fmt.Sprintf("✅ AI Model: *%s*\n\nPlease send your API Key:", choice.model), true
}
func (a *Agent) finishSetup(storeUserID string, userID int64, state *SetupState, L string) (string, bool) {
// Create exchange in store
a.logger.Info("creating trader from setup",
"exchange", state.Exchange,
"ai_model", state.AIModel,
"store_user_id", storeUserID,
)
// TODO: Use store to create exchange + trader config
// For now, log the config and tell user
a.clearSetupState(userID)
result := ""
maskedKey := maskKey(state.APIKey)
if L == "zh" {
result = fmt.Sprintf("🎉 *配置完成!*\n\n"+
"• 交易所: %s\n"+
"• API Key: %s\n",
titleCaser.String(state.Exchange), maskedKey)
if state.AIModel != "" {
result += fmt.Sprintf("• AI 模型: %s\n", state.AIModel)
}
result += "\n正在创建 Trader..."
} else {
result = fmt.Sprintf("🎉 *Setup Complete!*\n\n"+
"• Exchange: %s\n"+
"• API Key: %s\n",
titleCaser.String(state.Exchange), maskedKey)
if state.AIModel != "" {
result += fmt.Sprintf("• AI Model: %s\n", state.AIModel)
}
result += "\nCreating Trader..."
}
// Actually create the trader via store
err := a.createTraderFromSetupForStoreUser(storeUserID, state)
if err != nil {
a.logger.Error("create trader failed", "error", err)
if L == "zh" {
result += fmt.Sprintf("\n\n⚠ 创建失败: %v\n交易所配置已保存下次配置时可直接复用。\n也可以在 Web UI 中继续完成。", err)
} else {
result += fmt.Sprintf("\n\n⚠ Failed: %v\nYour exchange config was saved, so you can reuse it next time.\nYou can also finish setup in the Web UI.", err)
}
} else {
if L == "zh" {
result += "\n\n✅ Trader 已创建!现在你可以:\n• `/analyze BTC` — 分析市场\n• `/positions` — 查看持仓\n• 或者直接跟我聊天"
} else {
result += "\n\n✅ Trader created! Now you can:\n• `/analyze BTC` — analyze market\n• `/positions` — view positions\n• Or just chat with me"
}
}
return result, true
}
func (a *Agent) createTraderFromSetup(state *SetupState) error {
return a.createTraderFromSetupForStoreUser("default", state)
}
func (a *Agent) createTraderFromSetupForStoreUser(storeUserID string, state *SetupState) error {
if a.store == nil {
return fmt.Errorf("store not available")
}
exchangeID := state.ExchangeID
if exchangeID == "" {
var err error
exchangeID, err = a.saveSetupExchange(storeUserID, state)
if err != nil {
return fmt.Errorf("save exchange: %w", err)
}
}
aiModelID := state.AIModelID
if state.AIModel != "" && state.AIKey != "" && aiModelID == "" {
var err error
aiModelID, err = a.saveSetupAIModel(storeUserID, state)
if err != nil {
a.logger.Error("save AI model", "error", err)
}
}
// Reuse an existing trader if the same exchange/model pair already exists.
existingTraders, err := a.store.Trader().List(storeUserID)
if err != nil {
return fmt.Errorf("list traders: %w", err)
}
for _, existing := range existingTraders {
if existing.ExchangeID == exchangeID && existing.AIModelID == aiModelID {
a.logger.Info("reusing existing trader created via chat setup",
"trader", existing.Name,
"exchange_id", exchangeID,
"ai_model_id", aiModelID,
)
return nil
}
}
// Create trader config
exchangeIDShort := exchangeID
if len(exchangeIDShort) > 8 {
exchangeIDShort = exchangeIDShort[:8]
}
modelPart := aiModelID
if modelPart == "" {
modelPart = "manual"
}
trader := &store.Trader{
ID: fmt.Sprintf("%s_%s_%d", exchangeIDShort, modelPart, time.Now().UnixNano()),
Name: fmt.Sprintf("NOFXi-%s", titleCaser.String(state.Exchange)),
UserID: storeUserID,
ExchangeID: exchangeID,
AIModelID: aiModelID,
IsRunning: false,
}
if err := a.store.Trader().Create(trader); err != nil {
return fmt.Errorf("save trader: %w", err)
}
a.logger.Info("trader created via chat",
"trader", trader.Name,
"exchange", state.Exchange,
"ai", aiModelID,
)
return nil
}
func (a *Agent) saveSetupExchange(storeUserID string, state *SetupState) (string, error) {
if a.store == nil {
return "", fmt.Errorf("store not available")
}
hlWallet := ""
hlUnified := false
passphrase := state.Passphrase
apiKey := state.APIKey
apiSecret := state.APISecret
if state.Exchange == "hyperliquid" {
hlWallet = state.APISecret
apiKey = ""
apiSecret = state.APIKey
}
exchanges, err := a.store.Exchange().List(storeUserID)
if err != nil {
return "", err
}
for _, ex := range exchanges {
if ex.ExchangeType == state.Exchange && ex.AccountName == setupExchangeAccountName {
if err := a.store.Exchange().Update(
storeUserID, ex.ID, true,
apiKey, apiSecret, passphrase,
false,
hlWallet, hlUnified,
"", "", "",
"", "", "", 0,
); err != nil {
return "", err
}
return ex.ID, nil
}
}
return a.store.Exchange().Create(
storeUserID,
state.Exchange,
setupExchangeAccountName,
true,
apiKey, apiSecret, passphrase,
false,
hlWallet, hlUnified,
"", "", "",
"", "", "", 0,
)
}
func (a *Agent) saveSetupAIModel(storeUserID string, state *SetupState) (string, error) {
if a.store == nil {
return "", fmt.Errorf("store not available")
}
if state.AIProvider == "" {
return "", nil
}
modelID := state.AIProvider
if err := a.store.AIModel().Update(
storeUserID,
modelID,
true,
state.AIKey,
state.AIBaseURL,
state.AIModel,
); err != nil {
return "", err
}
modelID = fmt.Sprintf("%s_%s", storeUserID, state.AIProvider)
return modelID, nil
}
func maskKey(key string) string {
if len(key) <= 8 {
return "****"
}
return key[:4] + "****" + key[len(key)-4:]
}
func needsPassphrase(exchange string) bool {
return exchange == "okx" || exchange == "bitget" || exchange == "kucoin"
}
func containsAny(s string, words []string) bool {
for _, w := range words {
if strings.Contains(s, w) {
return true
}
}
return false
}
var setupMessages = map[string]map[string]string{
"welcome": {
"zh": "👋 你好!我是 *NOFXi*,你的 AI 交易 Agent。\n\n" +
"我发现你还没有配置交易所,让我帮你搞定吧!\n\n" +
"发送 *开始配置* 或 *setup* 开始\n" +
"发送 *取消* 随时退出",
"en": "👋 Hi! I'm *NOFXi*, your AI trading agent.\n\n" +
"I see you haven't configured an exchange yet. Let me help!\n\n" +
"Send *setup* to begin\n" +
"Send *cancel* to exit anytime",
},
"ask_exchange": {
"zh": "🏦 *选择你的交易所*\n\n" +
"1⃣ Binance币安\n" +
"2⃣ OKX欧易\n" +
"3⃣ Bybit\n" +
"4⃣ Bitget\n" +
"5⃣ Gate\n" +
"6⃣ KuCoin库币\n" +
"7⃣ Hyperliquid\n\n" +
"发送数字或名称选择:",
"en": "🏦 *Choose your exchange*\n\n" +
"1⃣ Binance\n" +
"2⃣ OKX\n" +
"3⃣ Bybit\n" +
"4⃣ Bitget\n" +
"5⃣ Gate\n" +
"6⃣ KuCoin\n" +
"7⃣ Hyperliquid\n\n" +
"Send number or name:",
},
"invalid_exchange": {
"zh": "❓ 没有识别到交易所。请发送数字 1-7 或交易所名称。",
"en": "❓ Exchange not recognized. Send a number 1-7 or exchange name.",
},
"ask_secret": {
"zh": "🔑 收到 API Key。\n\n现在请发送你的 *API Secret*",
"en": "🔑 Got API Key.\n\nNow send your *API Secret*:",
},
"ask_passphrase": {
"zh": "🔐 收到 API Secret。\n\n这个交易所还需要 *Passphrase*,请发送:",
"en": "🔐 Got API Secret.\n\nThis exchange also needs a *Passphrase*. Please send it:",
},
"ask_ai": {
"zh": "🤖 *选择 AI 模型*\n\n" +
"1⃣ DeepSeek推荐便宜好用\n" +
"2⃣ 通义千问 (Qwen)\n" +
"3⃣ OpenAI (GPT-4o)\n" +
"4⃣ Claude\n" +
"5⃣ 跳过(不配置 AI\n\n" +
"发送数字或名称选择:",
"en": "🤖 *Choose AI model*\n\n" +
"1⃣ DeepSeek (recommended, affordable)\n" +
"2⃣ Qwen\n" +
"3⃣ OpenAI (GPT-4o)\n" +
"4⃣ Claude\n" +
"5⃣ Skip (no AI)\n\n" +
"Send number or name:",
},
"invalid_ai": {
"zh": "❓ 没有识别到 AI 模型。请发送数字 1-5 或模型名称。",
"en": "❓ AI model not recognized. Send a number 1-5 or model name.",
},
"cancelled": {
"zh": "👌 配置已取消。随时发送 *开始配置* 重新开始。",
"en": "👌 Setup cancelled. Send *setup* anytime to restart.",
},
}
func (a *Agent) setupMsg(L, key string) string {
if m, ok := setupMessages[key]; ok {
if s, ok := m[L]; ok {
return s
}
return m["en"]
}
return key
}

View File

@@ -1,26 +0,0 @@
package agent
import "testing"
func TestIsDirectSetupCommand(t *testing.T) {
cases := []struct {
text string
want bool
}{
{text: "setup", want: true},
{text: "/setup", want: true},
{text: "开始配置", want: true},
{text: "配置", want: true},
{text: "开始设置", want: true},
{text: "/开始配置", want: false},
{text: "创建全新的配置,杠杆你定", want: false},
{text: "帮我配置一个 deepseek 模型", want: false},
{text: "绑定交易所 okx", want: false},
}
for _, tc := range cases {
if got := isDirectSetupCommand(tc.text); got != tc.want {
t.Fatalf("isDirectSetupCommand(%q) = %v, want %v", tc.text, got, tc.want)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,807 +0,0 @@
package agent
import (
"context"
"encoding/json"
"errors"
"log/slog"
"strings"
"testing"
"time"
"nofx/mcp"
)
func TestIsConfigOrTraderIntent(t *testing.T) {
cases := []struct {
text string
want bool
}{
{text: "帮我创建一个交易员", want: true},
{text: "我已经配置好了 OKX 和 DeepSeek", want: true},
{text: "List my traders", want: true},
{text: "BTC 接下来怎么看", want: false},
}
for _, tc := range cases {
if got := isConfigOrTraderIntent(tc.text); got != tc.want {
t.Fatalf("isConfigOrTraderIntent(%q) = %v, want %v", tc.text, got, tc.want)
}
}
}
func TestIsRealtimeAccountIntent(t *testing.T) {
cases := []struct {
text string
want bool
}{
{text: "现在余额多少", want: true},
{text: "我的仓位还在吗", want: true},
{text: "show recent trade history", want: true},
{text: "帮我创建交易员", want: false},
}
for _, tc := range cases {
if got := isRealtimeAccountIntent(tc.text); got != tc.want {
t.Fatalf("isRealtimeAccountIntent(%q) = %v, want %v", tc.text, got, tc.want)
}
}
}
func TestDetectReadFastPath(t *testing.T) {
cases := []struct {
text string
want string
}{
{text: "/traders", want: "list_traders"},
{text: "/strategies", want: "get_strategies"},
{text: "/models", want: "get_model_configs"},
{text: "/exchanges", want: "get_exchange_configs"},
{text: "/balance", want: "get_balance"},
{text: "/positions", want: "get_positions"},
{text: "/history", want: "get_trade_history"},
{text: "/trades", want: "get_trade_history"},
{text: "列出我当前的策略", want: ""},
{text: "查看当前交易员", want: ""},
{text: "现在余额多少", want: ""},
{text: "我的仓位还在吗", want: ""},
{text: "我现在有哪些账户", want: ""},
{text: "我的余额", want: ""},
{text: "根据我的余额帮我分析我应该买什么", want: ""},
{text: "我的策略是AI100但是No candidate coins available, cycle skipped", want: ""},
{text: "帮我创建一个 trader", want: ""},
}
for _, tc := range cases {
req := detectReadFastPath(tc.text)
got := ""
if req != nil {
got = req.Kind
}
if got != tc.want {
t.Fatalf("detectReadFastPath(%q) = %q, want %q", tc.text, got, tc.want)
}
}
}
func TestShouldResetExecutionStateForNewAttempt(t *testing.T) {
state := ExecutionState{
SessionID: "sess_1",
Status: executionStatusWaitingUser,
}
if !shouldResetExecutionStateForNewAttempt("我已经配置好了,继续创建交易员", state) {
t.Fatalf("expected retry-style config request to reset execution state")
}
if shouldResetExecutionStateForNewAttempt("BTC 价格多少", state) {
t.Fatalf("did not expect generic market query to reset execution state")
}
}
func TestLatestAskedQuestion(t *testing.T) {
state := ExecutionState{
Status: executionStatusWaitingUser,
Steps: []PlanStep{
{ID: "step_1", Type: planStepTypeTool, Status: planStepStatusCompleted},
{ID: "step_2", Type: planStepTypeAskUser, Status: planStepStatusCompleted, Instruction: "需要我用正确的参数重试创建交易员 lky 吗?"},
},
}
got := latestAskedQuestion(state)
want := "需要我用正确的参数重试创建交易员 lky 吗?"
if got != want {
t.Fatalf("latestAskedQuestion() = %q, want %q", got, want)
}
}
func TestLatestAskedQuestionPrefersStructuredWaitingState(t *testing.T) {
state := ExecutionState{
Status: executionStatusWaitingUser,
Waiting: &WaitingState{
Question: "请确认是否继续创建交易员 lky",
Intent: "confirm_action",
},
Steps: []PlanStep{
{ID: "step_2", Type: planStepTypeAskUser, Status: planStepStatusCompleted, Instruction: "旧问题"},
},
}
if got := latestAskedQuestion(state); got != "请确认是否继续创建交易员 lky" {
t.Fatalf("latestAskedQuestion() = %q, want structured waiting question", got)
}
}
func TestRefreshStateForDynamicRequestsAddsFreshSnapshots(t *testing.T) {
a := newTestAgentWithStore(t)
_ = a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"openai",
"enabled":true,
"custom_api_url":"https://api.openai.com/v1",
"custom_model_name":"gpt-5-mini"
}`)
_ = a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"okx",
"account_name":"Main",
"enabled":true
}`)
state := ExecutionState{
SessionID: "sess_1",
UserID: 1,
DynamicSnapshots: []Observation{
{Kind: "current_model_configs", Summary: "stale"},
},
ExecutionLog: []Observation{{Kind: "user_reply", Summary: "continue"}},
}
refreshed := a.refreshStateForDynamicRequests("user-1", "帮我创建交易员", state)
if len(refreshed.DynamicSnapshots) < 3 {
t.Fatalf("expected refreshed observations to include snapshots, got %+v", refreshed.DynamicSnapshots)
}
var foundModel, foundExchange, foundTraders bool
for _, obs := range refreshed.DynamicSnapshots {
switch obs.Kind {
case "current_model_configs":
foundModel = strings.Contains(obs.RawJSON, "openai")
case "current_exchange_configs":
foundExchange = strings.Contains(obs.RawJSON, "okx")
case "current_traders":
foundTraders = strings.Contains(obs.RawJSON, `"traders"`)
}
}
if !foundModel || !foundExchange || !foundTraders {
t.Fatalf("missing fresh snapshots: %+v", refreshed.DynamicSnapshots)
}
}
func TestRefreshStateForRealtimeAccountRequestsAddsFreshSnapshots(t *testing.T) {
a := newTestAgentWithStore(t)
state := ExecutionState{
SessionID: "sess_2",
UserID: 1,
DynamicSnapshots: []Observation{
{Kind: "current_balances", Summary: "stale balances"},
{Kind: "current_positions", Summary: "stale positions"},
},
ExecutionLog: []Observation{{Kind: "user_reply", Summary: "现在余额多少"}},
}
refreshed := a.refreshStateForDynamicRequests("user-1", "现在余额多少,我的仓位还在吗", state)
var keptBalances, keptPositions, foundHistory bool
for _, obs := range refreshed.DynamicSnapshots {
switch obs.Kind {
case "current_balances":
keptBalances = strings.Contains(obs.Summary, "stale balances")
case "current_positions":
keptPositions = strings.Contains(obs.Summary, "stale positions")
case "recent_trade_history":
foundHistory = obs.RawJSON != ""
}
}
if !keptBalances || !keptPositions || foundHistory {
t.Fatalf("expected realtime snapshots to stay untouched, got %+v", refreshed.DynamicSnapshots)
}
}
func TestThinkAndActNaturalLanguageReadCanBeHandledByHighLevelSkill(t *testing.T) {
a := newTestAgentWithStore(t)
_ = a.toolManageStrategy("user-1", `{
"action":"create",
"name":"激进",
"description":"激进策略模板",
"lang":"zh"
}`)
resp, err := a.thinkAndAct(context.Background(), "user-1", 1, "zh", "列出我当前的策略")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "当前策略") || !strings.Contains(resp, "激进") {
t.Fatalf("expected natural-language read to be handled by high-level skill, got %q", resp)
}
}
func TestNormalizeExecutionStateMigratesLegacyObservations(t *testing.T) {
state := normalizeExecutionState(ExecutionState{
SessionID: "sess_legacy",
UserID: 1,
Observations: []Observation{
{Kind: "tool_result", Summary: "legacy tool result"},
},
})
if len(state.Observations) != 0 {
t.Fatalf("expected legacy observations field to be cleared, got %+v", state.Observations)
}
if len(state.ExecutionLog) != 1 || state.ExecutionLog[0].Summary != "legacy tool result" {
t.Fatalf("expected legacy observations to migrate into execution log, got %+v", state.ExecutionLog)
}
}
func TestBuildWaitingStateForTraderConfirmation(t *testing.T) {
state := ExecutionState{Goal: "创建交易员 lky"}
step := PlanStep{
ID: "step_ask_1",
Type: planStepTypeAskUser,
Instruction: "需要我用正确的参数重试创建交易员 lky 吗?",
RequiresConfirmation: true,
}
waiting := buildWaitingState(state, step, step.Instruction)
if waiting == nil {
t.Fatal("expected waiting state")
}
if waiting.Intent != "confirm_action" {
t.Fatalf("unexpected waiting intent: %+v", waiting)
}
if waiting.ConfirmationTarget != "trader" {
t.Fatalf("unexpected confirmation target: %+v", waiting)
}
}
func TestNormalizeWaitingStateCleansFields(t *testing.T) {
state := normalizeExecutionState(ExecutionState{
SessionID: "sess_waiting",
UserID: 1,
Waiting: &WaitingState{
Question: " 请提供 strategy_id ",
Intent: " complete_trader_setup ",
PendingFields: []string{" strategy_id ", "strategy_id"},
ConfirmationTarget: " trader ",
},
})
if state.Waiting == nil {
t.Fatal("expected normalized waiting state")
}
if state.Waiting.Question != "请提供 strategy_id" {
t.Fatalf("unexpected normalized question: %+v", state.Waiting)
}
if len(state.Waiting.PendingFields) != 1 || state.Waiting.PendingFields[0] != "strategy_id" {
t.Fatalf("unexpected pending fields: %+v", state.Waiting)
}
if state.Waiting.ConfirmationTarget != "trader" {
t.Fatalf("unexpected confirmation target: %+v", state.Waiting)
}
}
func TestRefreshCurrentReferencesForUserTextMatchesStrategyName(t *testing.T) {
a := newTestAgentWithStore(t)
_ = a.toolManageStrategy("user-1", `{
"action":"create",
"name":"激进",
"description":"激进策略模板",
"lang":"zh"
}`)
state := newExecutionState(1, "帮我改一下激进这个策略")
a.refreshCurrentReferencesForUserText("user-1", "帮我改一下激进这个策略", &state)
if state.CurrentReferences == nil || state.CurrentReferences.Strategy == nil {
t.Fatalf("expected strategy reference, got %+v", state.CurrentReferences)
}
if state.CurrentReferences.Strategy.Name != "激进" {
t.Fatalf("unexpected strategy reference: %+v", state.CurrentReferences.Strategy)
}
}
func TestUpdateCurrentReferencesFromToolResultTracksCreatedStrategy(t *testing.T) {
state := newExecutionState(1, "创建策略")
changed := updateCurrentReferencesFromToolResult(&state, "manage_strategy", `{
"status":"ok",
"action":"create",
"strategy":{"id":"strategy_1","name":"激进"}
}`)
if !changed {
t.Fatalf("expected reference update to report changed")
}
if state.CurrentReferences == nil || state.CurrentReferences.Strategy == nil {
t.Fatalf("expected strategy reference after tool result, got %+v", state.CurrentReferences)
}
if state.CurrentReferences.Strategy.ID != "strategy_1" {
t.Fatalf("unexpected strategy reference: %+v", state.CurrentReferences.Strategy)
}
}
func TestShouldAttemptReplan(t *testing.T) {
state := ExecutionState{
Steps: []PlanStep{
{ID: "step_1", Type: planStepTypeTool, Status: planStepStatusCompleted},
{ID: "step_2", Type: planStepTypeRespond, Status: planStepStatusPending},
},
}
if !shouldAttemptReplan(state, PlanStep{
Type: planStepTypeTool,
ToolName: "manage_trader",
ToolArgs: map[string]any{"action": "create"},
OutputSummary: `{"status":"ok","action":"create"}`,
}, false) {
t.Fatalf("expected create trader step to trigger replan")
}
if shouldAttemptReplan(state, PlanStep{
Type: planStepTypeTool,
ToolName: "get_balance",
OutputSummary: `{"balances":[]}`,
}, false) {
t.Fatalf("did not expect read-only balance step to trigger replan")
}
if !shouldAttemptReplan(state, PlanStep{
Type: planStepTypeTool,
ToolName: "get_balance",
OutputSummary: `{"error":"ai_model_id is required"}`,
}, false) {
t.Fatalf("expected dependency/error result to trigger replan")
}
}
type failingAIClient struct{}
func (f *failingAIClient) SetAPIKey(string, string, string) {}
func (f *failingAIClient) SetTimeout(_ time.Duration) {}
func (f *failingAIClient) CallWithMessages(string, string) (string, error) {
return "", errors.New("unexpected CallWithMessages")
}
func (f *failingAIClient) CallWithRequest(*mcp.Request) (string, error) {
return "", errors.New("API returned error (status 402): insufficient balance")
}
func (f *failingAIClient) CallWithRequestStream(*mcp.Request, func(string)) (string, error) {
return "", errors.New("unexpected CallWithRequestStream")
}
func (f *failingAIClient) CallWithRequestFull(*mcp.Request) (*mcp.LLMResponse, error) {
return nil, errors.New("API returned error (status 402): insufficient balance")
}
type capturePlannerAIClient struct {
systemPrompt string
userPrompt string
}
func (c *capturePlannerAIClient) SetAPIKey(string, string, string) {}
func (c *capturePlannerAIClient) SetTimeout(time.Duration) {}
func (c *capturePlannerAIClient) CallWithMessages(string, string) (string, error) {
return "", errors.New("unexpected CallWithMessages")
}
func (c *capturePlannerAIClient) CallWithRequest(req *mcp.Request) (string, error) {
if len(req.Messages) > 0 {
c.systemPrompt = req.Messages[0].Content
}
if len(req.Messages) > 1 {
c.userPrompt = req.Messages[1].Content
}
return `{"goal":"test goal","steps":[{"id":"step_1","type":"respond","instruction":"ok"}]}`, nil
}
func (c *capturePlannerAIClient) CallWithRequestStream(*mcp.Request, func(string)) (string, error) {
return "", errors.New("unexpected CallWithRequestStream")
}
func (c *capturePlannerAIClient) CallWithRequestFull(*mcp.Request) (*mcp.LLMResponse, error) {
return nil, errors.New("unexpected CallWithRequestFull")
}
type blockingAIClient struct{}
func (b *blockingAIClient) SetAPIKey(string, string, string) {}
func (b *blockingAIClient) SetTimeout(time.Duration) {}
func (b *blockingAIClient) CallWithMessages(string, string) (string, error) {
return "", errors.New("unexpected CallWithMessages")
}
func (b *blockingAIClient) CallWithRequest(req *mcp.Request) (string, error) {
<-req.Ctx.Done()
return "", req.Ctx.Err()
}
func (b *blockingAIClient) CallWithRequestStream(*mcp.Request, func(string)) (string, error) {
return "", errors.New("unexpected CallWithRequestStream")
}
func (b *blockingAIClient) CallWithRequestFull(*mcp.Request) (*mcp.LLMResponse, error) {
return nil, errors.New("unexpected CallWithRequestFull")
}
type directReplyAIClient struct {
lastSystemPrompt string
lastUserPrompt string
routerPrompt string
skillRouterPrompt string
plannerPrompt string
}
func (d *directReplyAIClient) SetAPIKey(string, string, string) {}
func (d *directReplyAIClient) SetTimeout(time.Duration) {}
func (d *directReplyAIClient) CallWithMessages(string, string) (string, error) {
return "", errors.New("unexpected CallWithMessages")
}
func (d *directReplyAIClient) CallWithRequest(req *mcp.Request) (string, error) {
if len(req.Messages) > 0 {
d.lastSystemPrompt = req.Messages[0].Content
}
if len(req.Messages) > 1 {
d.lastUserPrompt = req.Messages[1].Content
}
if strings.Contains(d.lastSystemPrompt, "first-pass router for NOFXi") {
d.routerPrompt = d.lastSystemPrompt
if strings.Contains(d.lastUserPrompt, "你好") {
return `{"action":"direct_answer","answer":"你好,我在。想聊策略、配置还是排障?"}`, nil
}
return `{"action":"defer","answer":""}`, nil
}
if strings.Contains(d.lastSystemPrompt, "lightweight skill router for NOFXi") {
d.skillRouterPrompt = d.lastSystemPrompt
if strings.Contains(d.lastUserPrompt, "运行中的trader") || strings.Contains(d.lastUserPrompt, "有没有 trader 在跑") {
return `{"route":"skill","skill":"trader_management","action":"query","filter":"running_only"}`, nil
}
return `{"route":"planner","skill":"","action":"","filter":""}`, nil
}
if strings.Contains(d.lastSystemPrompt, "planning module for NOFXi") {
d.plannerPrompt = d.lastSystemPrompt
}
return `{"goal":"test goal","steps":[{"id":"step_1","type":"respond","instruction":"ok"}]}`, nil
}
func (d *directReplyAIClient) CallWithRequestStream(*mcp.Request, func(string)) (string, error) {
return "", errors.New("unexpected CallWithRequestStream")
}
func (d *directReplyAIClient) CallWithRequestFull(*mcp.Request) (*mcp.LLMResponse, error) {
return nil, errors.New("unexpected CallWithRequestFull")
}
func TestThinkAndActLegacyReturnsProviderFailureInsteadOfNoAIFallback(t *testing.T) {
a := &Agent{
aiClient: &failingAIClient{},
config: DefaultConfig(),
logger: slog.Default(),
history: newChatHistory(10),
}
resp, err := a.thinkAndActLegacy(context.Background(), 42, "zh", "你好", nil)
if err != nil {
t.Fatalf("thinkAndActLegacy() error = %v", err)
}
if strings.Contains(resp, "发送 *开始配置* 配置 AI 模型") {
t.Fatalf("expected provider failure message, got fallback: %q", resp)
}
if !strings.Contains(resp, "AI 服务调用失败") {
t.Fatalf("expected provider failure message, got %q", resp)
}
}
func TestThinkAndActUsesDirectReplyGateForConversationalQuestion(t *testing.T) {
client := &directReplyAIClient{}
a := &Agent{
aiClient: client,
config: DefaultConfig(),
logger: slog.Default(),
history: newChatHistory(10),
}
resp, err := a.thinkAndAct(context.Background(), "user-1", 88, "zh", "你好")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "你好,我在") {
t.Fatalf("expected direct reply response, got %q", resp)
}
if !strings.Contains(client.routerPrompt, "first-pass router for NOFXi") {
t.Fatalf("expected direct reply router prompt, got %q", client.routerPrompt)
}
}
func TestThinkAndActDefersFromDirectReplyGateToHardSkill(t *testing.T) {
a := newTestAgentWithStore(t)
a.aiClient = &directReplyAIClient{}
resp, err := a.thinkAndAct(context.Background(), "user-1", 89, "zh", "帮我创建一个 DeepSeek 模型配置")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "已创建模型配置") {
t.Fatalf("expected direct reply gate to defer to hard skill, got %q", resp)
}
}
func TestThinkAndActUsesLLMSkillRouterForNaturalLanguageTraderQuery(t *testing.T) {
client := &directReplyAIClient{}
a := newTestAgentWithStore(t)
a.aiClient = client
a.history = newChatHistory(10)
modelResp := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"openai",
"enabled":true,
"custom_api_url":"https://api.openai.com/v1",
"custom_model_name":"gpt-5-mini"
}`)
var modelCreated struct {
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(modelResp), &modelCreated); err != nil {
t.Fatalf("unmarshal model response: %v", err)
}
exchangeResp := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"binance",
"account_name":"Main",
"enabled":true
}`)
var exchangeCreated struct {
Exchange safeExchangeToolConfig `json:"exchange"`
}
if err := json.Unmarshal([]byte(exchangeResp), &exchangeCreated); err != nil {
t.Fatalf("unmarshal exchange response: %v", err)
}
createResp := a.toolManageTrader("user-1", `{
"action":"create",
"name":"Momentum Trader",
"ai_model_id":"`+modelCreated.Model.ID+`",
"exchange_id":"`+exchangeCreated.Exchange.ID+`",
"scan_interval_minutes":5
}`)
var created struct {
Trader safeTraderToolConfig `json:"trader"`
}
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
t.Fatalf("unmarshal create trader response: %v\nraw=%s", err, createResp)
}
if err := a.store.Trader().UpdateStatus("user-1", created.Trader.ID, true); err != nil {
t.Fatalf("update trader status: %v", err)
}
resp, err := a.thinkAndAct(context.Background(), "user-1", 90, "zh", "当前有运行中的trader吗")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "运行中的交易员") || !strings.Contains(resp, "Momentum Trader") {
t.Fatalf("expected routed running-trader answer, got %q", resp)
}
if client.skillRouterPrompt == "" {
t.Fatal("expected lightweight skill router prompt to be used")
}
if client.plannerPrompt != "" {
t.Fatalf("expected planner to be skipped, got prompt %q", client.plannerPrompt)
}
}
func TestThinkAndActPrioritizesActiveExecutionStateOverDirectReply(t *testing.T) {
client := &directReplyAIClient{}
a := newTestAgentWithStore(t)
a.aiClient = client
a.history = newChatHistory(10)
a.logger = slog.Default()
userID := int64(90)
state := newExecutionState(userID, "继续完成当前任务")
state.Status = executionStatusWaitingUser
state.Waiting = &WaitingState{
Question: "请确认是否继续",
Intent: "confirm_action",
}
if err := a.saveExecutionState(state); err != nil {
t.Fatalf("saveExecutionState() error = %v", err)
}
resp, err := a.thinkAndAct(context.Background(), "user-1", userID, "zh", "你好")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if strings.Contains(resp, "你好,我在") {
t.Fatalf("expected active execution state to bypass direct reply gate, got %q", resp)
}
if !strings.Contains(client.plannerPrompt, "planning module for NOFXi") {
t.Fatalf("expected planner prompt when execution state is active, got %q", client.plannerPrompt)
}
}
func TestThinkAndActInterruptsWaitingExecutionStateForNewTopic(t *testing.T) {
a := newTestAgentWithStore(t)
a.history = newChatHistory(10)
_ = a.toolManageStrategy("user-1", `{
"action":"create",
"name":"激进",
"lang":"zh"
}`)
userID := int64(91)
state := newExecutionState(userID, "创建交易员")
state.Status = executionStatusWaitingUser
state.Waiting = &WaitingState{
Question: "请告诉我交易员名称",
PendingFields: []string{"name"},
}
if err := a.saveExecutionState(state); err != nil {
t.Fatalf("saveExecutionState() error = %v", err)
}
resp, err := a.thinkAndAct(context.Background(), "user-1", userID, "zh", "列出我当前的策略")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "当前策略") || !strings.Contains(resp, "激进") {
t.Fatalf("expected new topic to be handled, got %q", resp)
}
if got := a.getExecutionState(userID); got.SessionID != "" {
t.Fatalf("expected execution state to be cleared, got %+v", got)
}
}
func TestCreateExecutionPlanIncludesRecentConversation(t *testing.T) {
client := &capturePlannerAIClient{}
a := &Agent{
aiClient: client,
config: DefaultConfig(),
logger: slog.Default(),
history: newChatHistory(10),
}
userID := int64(42)
a.history.Add(userID, "user", "先帮我看一下当前trader")
a.history.Add(userID, "assistant", "当前只有测试1这个trader。")
a.history.Add(userID, "user", "好的那就按当前trader来")
_, err := a.createExecutionPlan(context.Background(), userID, "zh", "好的那就按当前trader来", newExecutionState(userID, "好的那就按当前trader来"))
if err != nil {
t.Fatalf("createExecutionPlan() error = %v", err)
}
if !strings.Contains(client.userPrompt, "Recent conversation:") {
t.Fatalf("expected planner prompt to include recent conversation, got %q", client.userPrompt)
}
if !strings.Contains(client.userPrompt, "先帮我看一下当前trader") {
t.Fatalf("expected previous user turn in recent conversation, got %q", client.userPrompt)
}
if !strings.Contains(client.userPrompt, "当前只有测试1这个trader") {
t.Fatalf("expected previous assistant turn in recent conversation, got %q", client.userPrompt)
}
recentIdx := strings.Index(client.userPrompt, "Recent conversation:\n")
toolsIdx := strings.Index(client.userPrompt, "\n\nAvailable tools JSON:")
if recentIdx == -1 || toolsIdx == -1 || toolsIdx <= recentIdx {
t.Fatalf("expected recent conversation block boundaries, got %q", client.userPrompt)
}
recentBlock := client.userPrompt[recentIdx:toolsIdx]
if strings.Contains(recentBlock, "好的那就按当前trader来") {
t.Fatalf("expected current user text to stay out of recent conversation block, got %q", recentBlock)
}
if !strings.Contains(client.systemPrompt, "Memory priority order:") {
t.Fatalf("expected planner system prompt to include memory priority guidance, got %q", client.systemPrompt)
}
if !strings.Contains(client.systemPrompt, "Execution state JSON = current operational truth") {
t.Fatalf("expected planner system prompt to prioritize execution state, got %q", client.systemPrompt)
}
if !strings.Contains(client.systemPrompt, "Do not ask the user to repeat a fact") {
t.Fatalf("expected planner system prompt to forbid unnecessary repeated questions, got %q", client.systemPrompt)
}
}
func TestCreateExecutionPlanIncludesRecentConversationForFreshRequest(t *testing.T) {
client := &capturePlannerAIClient{}
a := &Agent{
aiClient: client,
config: DefaultConfig(),
logger: slog.Default(),
history: newChatHistory(10),
}
userID := int64(99)
a.history.Add(userID, "user", "先帮我看一下当前trader")
a.history.Add(userID, "assistant", "当前只有测试1这个trader。")
_, err := a.createExecutionPlan(context.Background(), userID, "zh", "帮我分析一下比特币", ExecutionState{})
if err != nil {
t.Fatalf("createExecutionPlan() error = %v", err)
}
if !strings.Contains(client.userPrompt, "Recent conversation:") {
t.Fatalf("expected fresh request to still include recent conversation block, got %q", client.userPrompt)
}
if !strings.Contains(client.userPrompt, "先帮我看一下当前trader") {
t.Fatalf("expected previous user turn in recent conversation, got %q", client.userPrompt)
}
if !strings.Contains(client.userPrompt, "当前只有测试1这个trader") {
t.Fatalf("expected previous assistant turn in recent conversation, got %q", client.userPrompt)
}
}
func TestCreateExecutionPlanIncludesQuotedEarlierAssistantClaim(t *testing.T) {
client := &capturePlannerAIClient{}
a := &Agent{
aiClient: client,
config: DefaultConfig(),
logger: slog.Default(),
history: newChatHistory(10),
}
userID := int64(100)
a.history.Add(userID, "user", "配置页怎么只有三个交易所")
a.history.Add(userID, "assistant", "目前你看到的是三个交易所。")
_, err := a.createExecutionPlan(context.Background(), userID, "zh", "你前面也跟我说只有三个交易所", ExecutionState{})
if err != nil {
t.Fatalf("createExecutionPlan() error = %v", err)
}
if !strings.Contains(client.userPrompt, "目前你看到的是三个交易所") {
t.Fatalf("expected planner prompt to include earlier assistant claim, got %q", client.userPrompt)
}
if !strings.Contains(client.userPrompt, "配置页怎么只有三个交易所") {
t.Fatalf("expected planner prompt to include earlier user complaint, got %q", client.userPrompt)
}
}
func TestRunPlannedAgentReturnsTimeoutMessageOnPlannerTimeout(t *testing.T) {
oldTimeout := plannerCreateTimeout
plannerCreateTimeout = 10 * time.Millisecond
defer func() { plannerCreateTimeout = oldTimeout }()
a := &Agent{
aiClient: &blockingAIClient{},
config: DefaultConfig(),
logger: slog.Default(),
history: newChatHistory(10),
}
resp, err := a.runPlannedAgent(context.Background(), "default", 7, "zh", "帮我分析一下当前市场", nil)
if err != nil {
t.Fatalf("runPlannedAgent() error = %v", err)
}
if !strings.Contains(resp, "处理超时") {
t.Fatalf("expected timeout message, got %q", resp)
}
}
func TestHandleMessageForStoreUserBypassesPlannerForTradeConfirmation(t *testing.T) {
a := &Agent{
config: DefaultConfig(),
logger: slog.Default(),
history: newChatHistory(10),
pending: newPendingTrades(),
}
resp, err := a.handleMessageForStoreUser(context.Background(), "default", 1, "确认 trade_missing")
if err != nil {
t.Fatalf("handleMessageForStoreUser() error = %v", err)
}
if !strings.Contains(resp, "交易已过期或不存在") {
t.Fatalf("expected direct trade confirmation handling, got %q", resp)
}
}
func TestResolveModelRuntimeConfigUsesProviderDefaults(t *testing.T) {
url, model := resolveModelRuntimeConfig("deepseek", "", "", "user_deepseek")
if url != "https://api.deepseek.com/v1" {
t.Fatalf("unexpected deepseek default url: %q", url)
}
if model != "deepseek-chat" {
t.Fatalf("unexpected deepseek default model: %q", model)
}
url, model = resolveModelRuntimeConfig("deepseek", "", "deepseek1", "user_deepseek")
if url != "https://api.deepseek.com/v1" {
t.Fatalf("unexpected resolved url: %q", url)
}
if model != "deepseek1" {
t.Fatalf("expected existing custom model name to win, got %q", model)
}
}

View File

@@ -1,161 +0,0 @@
package agent
import (
"encoding/json"
"fmt"
"hash/fnv"
"strings"
"time"
)
// PersistentPreference is a durable user instruction shown in the UI and
// injected into the agent context for future conversations.
type PersistentPreference struct {
ID string `json:"id"`
Text string `json:"text"`
CreatedAt string `json:"created_at,omitempty"`
}
func NewPersistentPreference(text string) (PersistentPreference, error) {
text = strings.TrimSpace(text)
if text == "" {
return PersistentPreference{}, fmt.Errorf("text required")
}
now := time.Now().UTC()
return PersistentPreference{
ID: now.Format("20060102150405.000000000"),
Text: text,
CreatedAt: now.Format(time.RFC3339),
}, nil
}
// SessionUserIDFromKey maps a stable user key (for example a UUID string from
// auth) to the int64 session id expected by the current agent implementation.
func SessionUserIDFromKey(userKey string) int64 {
if strings.TrimSpace(userKey) == "" {
return 1
}
h := fnv.New64a()
_, _ = h.Write([]byte(userKey))
sum := h.Sum64() & 0x7fffffffffffffff
if sum == 0 {
return 1
}
return int64(sum)
}
func PreferencesConfigKey(userID int64) string {
return fmt.Sprintf("agent_preferences_%d", userID)
}
func (a *Agent) getPersistentPreferences(userID int64) []PersistentPreference {
if a.store == nil {
return nil
}
raw, err := a.store.GetSystemConfig(PreferencesConfigKey(userID))
if err != nil || strings.TrimSpace(raw) == "" {
return nil
}
var prefs []PersistentPreference
if err := json.Unmarshal([]byte(raw), &prefs); err != nil {
a.logger.Warn("failed to parse persistent preferences", "error", err, "user_id", userID)
return nil
}
return prefs
}
func (a *Agent) savePersistentPreferences(userID int64, prefs []PersistentPreference) error {
if a.store == nil {
return fmt.Errorf("store unavailable")
}
data, err := json.Marshal(prefs)
if err != nil {
return err
}
return a.store.SetSystemConfig(PreferencesConfigKey(userID), string(data))
}
func (a *Agent) addPersistentPreference(userID int64, text string) ([]PersistentPreference, PersistentPreference, error) {
created, err := NewPersistentPreference(text)
if err != nil {
return nil, PersistentPreference{}, err
}
prefs := a.getPersistentPreferences(userID)
prefs = append([]PersistentPreference{created}, prefs...)
if len(prefs) > 20 {
prefs = prefs[:20]
}
if err := a.savePersistentPreferences(userID, prefs); err != nil {
return nil, PersistentPreference{}, err
}
return prefs, created, nil
}
func (a *Agent) updatePersistentPreference(userID int64, match, replacement string) ([]PersistentPreference, *PersistentPreference, error) {
match = strings.TrimSpace(match)
replacement = strings.TrimSpace(replacement)
if match == "" || replacement == "" {
return nil, nil, fmt.Errorf("match and replacement are required")
}
prefs := a.getPersistentPreferences(userID)
for i := range prefs {
if prefs[i].ID == match || strings.Contains(strings.ToLower(prefs[i].Text), strings.ToLower(match)) {
prefs[i].Text = replacement
if err := a.savePersistentPreferences(userID, prefs); err != nil {
return nil, nil, err
}
return prefs, &prefs[i], nil
}
}
return prefs, nil, fmt.Errorf("preference not found")
}
func (a *Agent) deletePersistentPreference(userID int64, match string) ([]PersistentPreference, *PersistentPreference, error) {
match = strings.TrimSpace(match)
if match == "" {
return nil, nil, fmt.Errorf("match required")
}
prefs := a.getPersistentPreferences(userID)
filtered := make([]PersistentPreference, 0, len(prefs))
var removed *PersistentPreference
for i := range prefs {
p := prefs[i]
if removed == nil && (p.ID == match || strings.Contains(strings.ToLower(p.Text), strings.ToLower(match))) {
cp := p
removed = &cp
continue
}
filtered = append(filtered, p)
}
if removed == nil {
return prefs, nil, fmt.Errorf("preference not found")
}
if err := a.savePersistentPreferences(userID, filtered); err != nil {
return nil, nil, err
}
return filtered, removed, nil
}
func (a *Agent) buildPersistentPreferencesContext(userID int64) string {
prefs := a.getPersistentPreferences(userID)
if len(prefs) == 0 {
return ""
}
var sb strings.Builder
sb.WriteString("[Persistent User Preferences - follow unless the user explicitly overrides them]\n")
for _, pref := range prefs {
if strings.TrimSpace(pref.Text) == "" {
continue
}
sb.WriteString("- ")
sb.WriteString(pref.Text)
sb.WriteString("\n")
}
return strings.TrimSpace(sb.String())
}

View File

@@ -1,31 +0,0 @@
package agent
import (
"strings"
"testing"
)
func TestNewPersistentPreference(t *testing.T) {
pref, err := NewPersistentPreference(" Always answer in Chinese. ")
if err != nil {
t.Fatalf("expected preference to be created, got error: %v", err)
}
if pref.ID == "" {
t.Fatal("expected non-empty preference id")
}
if pref.Text != "Always answer in Chinese." {
t.Fatalf("expected trimmed text, got %q", pref.Text)
}
if pref.CreatedAt == "" {
t.Fatal("expected created_at to be set")
}
if strings.Contains(pref.ID, "Always") {
t.Fatalf("expected generated id, got %q", pref.ID)
}
}
func TestNewPersistentPreferenceRejectsEmptyText(t *testing.T) {
if _, err := NewPersistentPreference(" "); err == nil {
t.Fatal("expected empty text to be rejected")
}
}

View File

@@ -1,107 +0,0 @@
package agent
import (
"context"
"fmt"
"log/slog"
"nofx/safe"
"strings"
"sync"
"time"
)
type Scheduler struct {
agent *Agent
logger *slog.Logger
stopCh chan struct{}
stopOnce sync.Once
}
func NewScheduler(a *Agent, l *slog.Logger) *Scheduler {
return &Scheduler{agent: a, logger: l, stopCh: make(chan struct{})}
}
func (s *Scheduler) Start(ctx context.Context) {
safe.GoNamed("agent-scheduler", func() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
lastReport := time.Time{}
lastCheck := time.Time{}
for {
select {
case <-ctx.Done(): return
case <-s.stopCh: return
case now := <-ticker.C:
// Daily report at 21:00
if now.Hour() == 21 && now.Sub(lastReport) > 12*time.Hour {
s.dailyReport()
lastReport = now
}
// Position risk check every 4h
if now.Sub(lastCheck) > 4*time.Hour {
s.riskCheck()
lastCheck = now
}
// Clean expired pending trades every hour.
if now.Minute() == 0 {
if s.agent.pending != nil {
s.agent.pending.CleanExpired()
}
}
}
}
})
}
func (s *Scheduler) Stop() { s.stopOnce.Do(func() { close(s.stopCh) }) }
func (s *Scheduler) dailyReport() {
if s.agent.traderManager == nil { return }
traders := s.agent.traderManager.GetAllTraders()
if len(traders) == 0 { return }
var sb strings.Builder
sb.WriteString(fmt.Sprintf("📊 *NOFXi 每日报告 — %s*\n\n", time.Now().Format("2006-01-02")))
totalPnL := 0.0
for _, t := range traders {
info, err := t.GetAccountInfo()
if err != nil { continue }
equity := toFloat(info["total_equity"])
pnl := toFloat(info["unrealized_pnl"])
sb.WriteString(fmt.Sprintf("• %s: $%.2f (P/L: $%.2f)\n", t.GetName(), equity, pnl))
totalPnL += pnl
}
e := "📈"
if totalPnL < 0 { e = "📉" }
sb.WriteString(fmt.Sprintf("\n%s Total P/L: $%.2f", e, totalPnL))
s.agent.notifyAll(sb.String())
}
func (s *Scheduler) riskCheck() {
if s.agent.traderManager == nil { return }
var alerts []string
for _, t := range s.agent.traderManager.GetAllTraders() {
positions, err := t.GetPositions()
if err != nil { continue }
for _, p := range positions {
pnl := toFloat(p["unrealizedPnl"])
size := toFloat(p["size"])
if size == 0 { continue }
entry := toFloat(p["entryPrice"])
if entry > 0 {
pnlPct := (pnl / (entry * size)) * 100
if pnlPct < -5 {
alerts = append(alerts, fmt.Sprintf("⚠️ *%s* %s: %.1f%% ($%.2f)",
p["symbol"], p["side"], pnlPct, pnl))
}
}
}
}
if len(alerts) > 0 {
s.agent.notifyAll("🚨 *持仓风险提醒*\n\n" + strings.Join(alerts, "\n"))
}
}

View File

@@ -1,173 +0,0 @@
package agent
import (
"encoding/json"
"fmt"
"log/slog"
"math"
"net/http"
"nofx/safe"
"strconv"
"strings"
"sync"
"time"
)
type SignalType string
const (
SignalPriceBreakout SignalType = "price_breakout"
SignalVolumeSpike SignalType = "volume_spike"
SignalFundingRate SignalType = "funding_rate"
)
type Signal struct {
Type SignalType
Symbol string
Severity string
Title string
Detail string
Price float64
Change float64
}
type SignalCallback func(Signal)
type Sentinel struct {
mu sync.RWMutex
symbols []string
history map[string][]pricePt
onSignal SignalCallback
http *http.Client
logger *slog.Logger
stopCh chan struct{}
stopOnce sync.Once
}
type pricePt struct {
Price float64
Volume float64
Time time.Time
}
func NewSentinel(symbols []string, cb SignalCallback, logger *slog.Logger) *Sentinel {
return &Sentinel{
symbols: symbols,
history: make(map[string][]pricePt),
onSignal: cb,
http: &http.Client{Timeout: 10 * time.Second},
logger: logger,
stopCh: make(chan struct{}),
}
}
func (s *Sentinel) Start() {
safe.GoNamed("sentinel", func() {
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
s.scan()
for {
select {
case <-s.stopCh:
return
case <-ticker.C:
s.scan()
}
}
})
}
func (s *Sentinel) Stop() { s.stopOnce.Do(func() { close(s.stopCh) }) }
func (s *Sentinel) SymbolCount() int { s.mu.RLock(); defer s.mu.RUnlock(); return len(s.symbols) }
func (s *Sentinel) AddSymbol(sym string) { s.mu.Lock(); defer s.mu.Unlock(); for _, x := range s.symbols { if x == sym { return } }; s.symbols = append(s.symbols, sym) }
func (s *Sentinel) RemoveSymbol(sym string) { s.mu.Lock(); defer s.mu.Unlock(); for i, x := range s.symbols { if x == sym { s.symbols = append(s.symbols[:i], s.symbols[i+1:]...); return } } }
func (s *Sentinel) FormatWatchlist(L string) string {
s.mu.RLock()
defer s.mu.RUnlock()
if len(s.symbols) == 0 {
if L == "zh" { return "📭 监控列表为空。用 `/watch BTC` 添加。" }
return "📭 Watchlist empty. Use `/watch BTC` to add."
}
var sb strings.Builder
if L == "zh" { sb.WriteString("👁️ *监控列表*\n\n") } else { sb.WriteString("👁️ *Watchlist*\n\n") }
for _, sym := range s.symbols {
if pts, ok := s.history[sym]; ok && len(pts) > 0 {
last := pts[len(pts)-1]
sb.WriteString(fmt.Sprintf("• *%s*: $%.4f (%s)\n", sym, last.Price, last.Time.Format("15:04")))
} else {
sb.WriteString(fmt.Sprintf("• *%s*: waiting...\n", sym))
}
}
return sb.String()
}
func (s *Sentinel) scan() {
s.mu.RLock()
syms := make([]string, len(s.symbols))
copy(syms, s.symbols)
s.mu.RUnlock()
for _, sym := range syms {
s.check(sym)
}
}
func (s *Sentinel) check(symbol string) {
resp, err := s.http.Get(fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", symbol))
if err != nil { return }
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
s.logger.Debug("sentinel ticker non-200", "symbol", symbol, "status", resp.StatusCode)
return
}
body, err := safe.ReadAllLimited(resp.Body, 256*1024) // 256KB limit
if err != nil { return }
var t map[string]interface{}
if err := json.Unmarshal(body, &t); err != nil { return }
price, _ := strconv.ParseFloat(fmt.Sprint(t["lastPrice"]), 64)
vol, _ := strconv.ParseFloat(fmt.Sprint(t["quoteVolume"]), 64)
chg, _ := strconv.ParseFloat(fmt.Sprint(t["priceChangePercent"]), 64)
pt := pricePt{Price: price, Volume: vol, Time: time.Now()}
s.mu.Lock()
h := s.history[symbol]
h = append(h, pt)
if len(h) > 60 { h = h[len(h)-60:] }
s.history[symbol] = h
s.mu.Unlock()
if len(h) < 5 { return }
// Price breakout (>3% in 5 min)
old := h[len(h)-5]
pct := ((price - old.Price) / old.Price) * 100
if math.Abs(pct) >= 3.0 {
sev := "warning"
if math.Abs(pct) >= 6.0 { sev = "critical" }
dir := "📈 拉升"
if pct < 0 { dir = "📉 下跌" }
s.emit(Signal{Type: SignalPriceBreakout, Symbol: symbol, Severity: sev,
Title: fmt.Sprintf("%s %s %.1f%%", symbol, dir, math.Abs(pct)),
Detail: fmt.Sprintf("5min: $%.2f → $%.2f (24h: %.1f%%)", old.Price, price, chg),
Price: price, Change: pct})
}
// Volume spike (>3x avg)
if len(h) >= 10 {
var avg float64
for i := 0; i < len(h)-1; i++ { avg += h[i].Volume }
avg /= float64(len(h) - 1)
if avg > 0 && vol > avg*3 {
s.emit(Signal{Type: SignalVolumeSpike, Symbol: symbol, Severity: "warning",
Title: fmt.Sprintf("%s 成交量异常 %.1fx", symbol, vol/avg),
Detail: fmt.Sprintf("Price: $%.2f (24h: %.1f%%)", price, chg),
Price: price, Change: chg})
}
}
}
func (s *Sentinel) emit(sig Signal) {
s.logger.Info("signal", "type", sig.Type, "symbol", sig.Symbol, "title", sig.Title)
if s.onSignal != nil { s.onSignal(sig) }
}

View File

@@ -1,97 +0,0 @@
package agent
func skillCatalogPrompt(lang string) string {
if lang == "zh" {
return `## 多轮与 Skill-First 工作模式
- 对于高频已知任务,优先按 skill 执行,不要每次从零规划
- 如果用户仍在同一任务里,继续当前 flow不要重新路由
- 只追问继续执行所需的最少必要字段,不要让用户重复已确认信息
- 高风险动作(删除、启动实盘、停止运行中 trader、覆盖关键配置必须单独确认
- 对诊断类问题,优先做“问题归类 -> 可能原因 -> 核查项 -> 下一步建议”
## 当前重点技能
### 1. 模型配置与诊断
- ` + "`skill_model_api_setup`" + `:用户问某个大模型的 API key 去哪申请、base URL 怎么填、model name 怎么填时,给步骤化指导
- ` + "`skill_model_config_diagnosis`" + `:当用户遇到模型配置失败、调用失败、保存后不可用时,优先检查:
1. 是否已启用模型
2. API Key 是否为空
3. custom_api_url 是否为合法 HTTPS 地址
4. custom_model_name 是否为空或填错
5. 保存后是否需要重新加载 trader
- 已知事实:
- 系统会拒绝非 HTTPS 的 custom_api_url
- 已启用模型如果缺少 API Key 或 custom_api_url会导致 agent 不可用
### 2. 交易所配置与诊断
- ` + "`skill_exchange_api_setup`" + `:指导用户创建交易所 API明确需要哪些权限、哪些权限不要开、哪些交易所需要额外字段
- ` + "`skill_exchange_api_diagnosis`" + `:用户遇到 invalid signature、timestamp、permission denied、IP not allowed 时,优先排查:
1. 系统时间是否同步
2. API Key / Secret 是否填反或过期
3. IP 白名单是否包含服务器 IP
4. 是否启用了合约/交易权限
5. OKX 是否遗漏 passphrase
- 已知事实:
- OKX 除 API Key 和 Secret 外还需要 passphrase
- invalid signature / timestamp 常见根因是时间不同步或密钥不匹配
### 3. Trader 启动与运行诊断
- ` + "`skill_trader_start_diagnosis`" + `:当用户说 trader 启动不了、启动后不交易、没有持仓、没有决策时,优先排查:
1. 是否存在可用且启用的模型配置
2. 是否存在可用且启用的交易所配置
3. trader 绑定的 strategy / exchange / model 是否齐全
4. 账户余额和权限是否满足下单要求
5. AI 是否一直返回 wait / hold
- 如果用户问“为什么没有开仓”,要明确区分:
- 系统没启动
- 启动了但 AI 决策为 wait
- 有信号但下单失败
### 4. 交易行为异常诊断
- ` + "`skill_order_execution_diagnosis`" + `:当用户问仓位开不出来、只开单边、杠杆报错时,优先排查:
1. 是否为交易所模式问题(例如 Binance One-way / Hedge Mode
2. 是否为子账户杠杆限制
3. 是否为合约权限或 symbol 不可交易
4. 是否为余额不足或保证金占用过高
- 已知事实:
- Binance 若不是 Hedge Mode可能出现 position side mismatch 或只开单边
- 某些子账户杠杆受限,超过限制会直接报错
### 5. 策略与提示词诊断
- ` + "`skill_strategy_diagnosis`" + `:当用户说策略没生效、提示词不对、预览和实际不一致时,优先建议:
1. 查看当前 strategy 配置
2. 区分策略模板本身和 trader 上的 custom prompt
3. 必要时预览 prompt 或读取当前保存值后再判断
## 回答格式要求
- 诊断类问题尽量按“现象 / 原因 / 先检查什么 / 怎么修复”回答
- 配置指导类问题尽量按步骤回答
- 如果已有工具能验证当前状态,先查再下结论
- 如果结论是推测,必须明确说是“更可能”或“优先怀疑”`
}
return `## Multi-turn and Skill-First Operating Mode
- For high-frequency known tasks, prefer stable skills instead of replanning from scratch
- If the user is still in the same task, continue the active flow
- Ask only for the minimum missing fields required to proceed
- Require explicit confirmation for destructive or financially sensitive actions
- For diagnostic requests, use: issue class -> likely causes -> checks -> next steps
## Priority Skills
- skill_model_api_setup / skill_model_config_diagnosis
- skill_exchange_api_setup / skill_exchange_api_diagnosis
- skill_trader_start_diagnosis
- skill_order_execution_diagnosis
- skill_strategy_diagnosis
Known facts:
- custom_api_url must be a valid HTTPS URL
- OKX requires passphrase in addition to API key and secret
- invalid signature / timestamp often means clock skew or mismatched credentials
- missing enabled model or exchange config can block trader startup
- Binance position-side issues are often caused by One-way Mode vs Hedge Mode
Response style:
- Diagnostics: symptom -> cause -> checks -> fix
- Setup guidance: step-by-step
- Verify with tools when possible before concluding`
}

View File

@@ -1,35 +0,0 @@
package agent
import (
"log/slog"
"strings"
"testing"
)
func TestSkillCatalogPromptZHIncludesDiagnosisSkills(t *testing.T) {
got := skillCatalogPrompt("zh")
for _, want := range []string{
"多轮与 Skill-First 工作模式",
"skill_model_config_diagnosis",
"skill_exchange_api_diagnosis",
"skill_trader_start_diagnosis",
} {
if !strings.Contains(got, want) {
t.Fatalf("skillCatalogPrompt(zh) missing %q\n%s", want, got)
}
}
}
func TestBuildSystemPromptIncludesSkillCatalog(t *testing.T) {
a := New(nil, nil, DefaultConfig(), slog.Default())
got := a.buildSystemPrompt("zh")
for _, want := range []string{
"多轮与 Skill-First 工作模式",
"skill_exchange_api_setup",
"skill_order_execution_diagnosis",
} {
if !strings.Contains(got, want) {
t.Fatalf("buildSystemPrompt(zh) missing %q", want)
}
}
}

View File

@@ -1,277 +0,0 @@
package agent
import "strings"
type SkillDAG struct {
SkillName string
Action string
Steps []SkillDAGStep
}
type SkillDAGStep struct {
ID string
Kind string
RequiredFields []string
OptionalFields []string
Next []string
Terminal bool
}
var skillDAGRegistry = buildSkillDAGRegistry()
func buildSkillDAGRegistry() map[string]SkillDAG {
dags := []SkillDAG{
{
SkillName: "trader_management",
Action: "create",
Steps: []SkillDAGStep{
{ID: "resolve_name", Kind: "collect_slot", RequiredFields: []string{"name"}, Next: []string{"resolve_exchange"}},
{ID: "resolve_exchange", Kind: "collect_slot", RequiredFields: []string{"exchange_id"}, OptionalFields: []string{"exchange_name"}, Next: []string{"resolve_model"}},
{ID: "resolve_model", Kind: "collect_slot", RequiredFields: []string{"model_id"}, OptionalFields: []string{"model_name"}, Next: []string{"resolve_strategy"}},
{ID: "resolve_strategy", Kind: "collect_slot", RequiredFields: []string{"strategy_id"}, OptionalFields: []string{"strategy_name"}, Next: []string{"maybe_confirm_start"}},
{ID: "maybe_confirm_start", Kind: "branch", OptionalFields: []string{"auto_start"}, Next: []string{"await_start_confirmation", "execute_create_only"}},
{ID: "await_start_confirmation", Kind: "confirm", RequiredFields: []string{"auto_start"}, Next: []string{"execute_create_and_start", "execute_create_only"}},
{ID: "execute_create_only", Kind: "execute", RequiredFields: []string{"name", "exchange_id", "model_id", "strategy_id"}, Terminal: true},
{ID: "execute_create_and_start", Kind: "execute", RequiredFields: []string{"name", "exchange_id", "model_id", "strategy_id"}, OptionalFields: []string{"auto_start"}, Terminal: true},
},
},
{
SkillName: "trader_management",
Action: "update_name",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_name"}},
{ID: "collect_name", Kind: "collect_slot", RequiredFields: []string{"name"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "name"}, Terminal: true},
},
},
{
SkillName: "trader_management",
Action: "update_bindings",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_bindings"}},
{ID: "collect_bindings", Kind: "collect_slot", RequiredFields: []string{"binding_update"}, OptionalFields: []string{"ai_model_id", "exchange_id", "strategy_id"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "binding_update"}, OptionalFields: []string{"ai_model_id", "exchange_id", "strategy_id"}, Terminal: true},
},
},
{
SkillName: "trader_management",
Action: "start",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"await_confirmation"}},
{ID: "await_confirmation", Kind: "confirm", RequiredFields: []string{"target_ref"}, Next: []string{"execute_start"}},
{ID: "execute_start", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true},
},
},
{
SkillName: "trader_management",
Action: "stop",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"await_confirmation"}},
{ID: "await_confirmation", Kind: "confirm", RequiredFields: []string{"target_ref"}, Next: []string{"execute_stop"}},
{ID: "execute_stop", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true},
},
},
{
SkillName: "trader_management",
Action: "delete",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"await_confirmation"}},
{ID: "await_confirmation", Kind: "confirm", RequiredFields: []string{"target_ref"}, Next: []string{"execute_delete"}},
{ID: "execute_delete", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true},
},
},
{
SkillName: "strategy_management",
Action: "create",
Steps: []SkillDAGStep{
{ID: "resolve_name", Kind: "collect_slot", RequiredFields: []string{"name"}, OptionalFields: []string{"lang", "description", "config"}, Next: []string{"execute_create"}},
{ID: "execute_create", Kind: "execute", RequiredFields: []string{"name"}, OptionalFields: []string{"lang", "description", "config"}, Terminal: true},
},
},
{
SkillName: "strategy_management",
Action: "update_name",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_name"}},
{ID: "collect_name", Kind: "collect_slot", RequiredFields: []string{"name"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "name"}, Terminal: true},
},
},
{
SkillName: "strategy_management",
Action: "update_prompt",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_prompt"}},
{ID: "collect_prompt", Kind: "collect_slot", RequiredFields: []string{"prompt"}, Next: []string{"load_config"}},
{ID: "load_config", Kind: "load_state", RequiredFields: []string{"target_ref"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "prompt"}, Terminal: true},
},
},
{
SkillName: "strategy_management",
Action: "update_config",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"resolve_config_field"}},
{ID: "resolve_config_field", Kind: "collect_slot", RequiredFields: []string{"config_field"}, Next: []string{"resolve_config_value"}},
{ID: "resolve_config_value", Kind: "collect_slot", RequiredFields: []string{"config_value"}, Next: []string{"load_config"}},
{ID: "load_config", Kind: "load_state", RequiredFields: []string{"target_ref"}, Next: []string{"apply_field_update"}},
{ID: "apply_field_update", Kind: "transform", RequiredFields: []string{"config_field", "config_value"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "config_field", "config_value"}, Terminal: true},
},
},
{
SkillName: "strategy_management",
Action: "duplicate",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_name"}},
{ID: "collect_name", Kind: "collect_slot", RequiredFields: []string{"name"}, Next: []string{"execute_duplicate"}},
{ID: "execute_duplicate", Kind: "execute", RequiredFields: []string{"target_ref", "name"}, Terminal: true},
},
},
{
SkillName: "strategy_management",
Action: "activate",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"execute_activate"}},
{ID: "execute_activate", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true},
},
},
{
SkillName: "strategy_management",
Action: "delete",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"await_confirmation"}},
{ID: "await_confirmation", Kind: "confirm", RequiredFields: []string{"target_ref"}, Next: []string{"execute_delete"}},
{ID: "execute_delete", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true},
},
},
{
SkillName: "model_management",
Action: "create",
Steps: []SkillDAGStep{
{ID: "resolve_provider", Kind: "collect_slot", RequiredFields: []string{"provider"}, Next: []string{"collect_optional_fields"}},
{ID: "collect_optional_fields", Kind: "collect_slot", OptionalFields: []string{"name", "custom_api_url", "custom_model_name"}, Next: []string{"execute_create"}},
{ID: "execute_create", Kind: "execute", RequiredFields: []string{"provider"}, OptionalFields: []string{"name", "custom_api_url", "custom_model_name"}, Terminal: true},
},
},
{
SkillName: "model_management",
Action: "update_status",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_enabled"}},
{ID: "collect_enabled", Kind: "collect_slot", RequiredFields: []string{"enabled"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "enabled"}, Terminal: true},
},
},
{
SkillName: "model_management",
Action: "update_endpoint",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_custom_api_url"}},
{ID: "collect_custom_api_url", Kind: "collect_slot", RequiredFields: []string{"custom_api_url"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "custom_api_url"}, Terminal: true},
},
},
{
SkillName: "model_management",
Action: "update_name",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_custom_model_name"}},
{ID: "collect_custom_model_name", Kind: "collect_slot", RequiredFields: []string{"custom_model_name"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "custom_model_name"}, Terminal: true},
},
},
{
SkillName: "model_management",
Action: "delete",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"await_confirmation"}},
{ID: "await_confirmation", Kind: "confirm", RequiredFields: []string{"target_ref"}, Next: []string{"execute_delete"}},
{ID: "execute_delete", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true},
},
},
{
SkillName: "exchange_management",
Action: "create",
Steps: []SkillDAGStep{
{ID: "resolve_exchange_type", Kind: "collect_slot", RequiredFields: []string{"exchange_type"}, Next: []string{"collect_account_name"}},
{ID: "collect_account_name", Kind: "collect_slot", OptionalFields: []string{"account_name"}, Next: []string{"execute_create"}},
{ID: "execute_create", Kind: "execute", RequiredFields: []string{"exchange_type"}, OptionalFields: []string{"account_name"}, Terminal: true},
},
},
{
SkillName: "exchange_management",
Action: "update_name",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_account_name"}},
{ID: "collect_account_name", Kind: "collect_slot", RequiredFields: []string{"account_name"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "account_name"}, Terminal: true},
},
},
{
SkillName: "exchange_management",
Action: "update_status",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_enabled"}},
{ID: "collect_enabled", Kind: "collect_slot", RequiredFields: []string{"enabled"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "enabled"}, Terminal: true},
},
},
{
SkillName: "exchange_management",
Action: "delete",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"await_confirmation"}},
{ID: "await_confirmation", Kind: "confirm", RequiredFields: []string{"target_ref"}, Next: []string{"execute_delete"}},
{ID: "execute_delete", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true},
},
},
}
registry := make(map[string]SkillDAG, len(dags))
for _, dag := range dags {
dag = normalizeSkillDAG(dag)
if dag.SkillName == "" || dag.Action == "" {
continue
}
registry[skillDAGKey(dag.SkillName, dag.Action)] = dag
}
return registry
}
func normalizeSkillDAG(dag SkillDAG) SkillDAG {
dag.SkillName = strings.TrimSpace(dag.SkillName)
dag.Action = strings.TrimSpace(dag.Action)
steps := make([]SkillDAGStep, 0, len(dag.Steps))
for _, step := range dag.Steps {
step.ID = strings.TrimSpace(step.ID)
step.Kind = strings.TrimSpace(step.Kind)
step.RequiredFields = cleanStringList(step.RequiredFields)
step.OptionalFields = cleanStringList(step.OptionalFields)
step.Next = cleanStringList(step.Next)
if step.ID == "" {
continue
}
steps = append(steps, step)
}
dag.Steps = steps
return dag
}
func skillDAGKey(skillName, action string) string {
return strings.TrimSpace(skillName) + ":" + strings.TrimSpace(action)
}
func getSkillDAG(skillName, action string) (SkillDAG, bool) {
dag, ok := skillDAGRegistry[skillDAGKey(skillName, action)]
return dag, ok
}
func listSkillDAGs() []SkillDAG {
out := make([]SkillDAG, 0, len(skillDAGRegistry))
for _, dag := range skillDAGRegistry {
out = append(out, dag)
}
return out
}

View File

@@ -1,51 +0,0 @@
package agent
const skillDAGStepField = "_dag_step"
func currentSkillDAGStep(session skillSession) (SkillDAGStep, bool) {
dag, ok := getSkillDAG(session.Name, session.Action)
if !ok || len(dag.Steps) == 0 {
return SkillDAGStep{}, false
}
stepID := fieldValue(session, skillDAGStepField)
if stepID == "" {
return dag.Steps[0], true
}
for _, step := range dag.Steps {
if step.ID == stepID {
return step, true
}
}
return dag.Steps[0], true
}
func setSkillDAGStep(session *skillSession, stepID string) {
ensureSkillFields(session)
if stepID == "" {
delete(session.Fields, skillDAGStepField)
return
}
session.Fields[skillDAGStepField] = stepID
}
func clearSkillDAGStep(session *skillSession) {
if session == nil || session.Fields == nil {
return
}
delete(session.Fields, skillDAGStepField)
}
func advanceSkillDAGStep(session *skillSession, currentStepID string) {
dag, ok := getSkillDAG(session.Name, session.Action)
if !ok {
return
}
for _, step := range dag.Steps {
if step.ID != currentStepID || len(step.Next) == 0 {
continue
}
setSkillDAGStep(session, step.Next[0])
return
}
}

View File

@@ -1,27 +0,0 @@
package agent
import "testing"
func TestCurrentSkillDAGStepDefaultsToFirstStep(t *testing.T) {
session := skillSession{Name: "strategy_management", Action: "update_config"}
step, ok := currentSkillDAGStep(session)
if !ok {
t.Fatal("expected dag step")
}
if step.ID != "resolve_target" {
t.Fatalf("expected first step resolve_target, got %s", step.ID)
}
}
func TestAdvanceSkillDAGStepMovesToNextStep(t *testing.T) {
session := skillSession{Name: "strategy_management", Action: "update_config"}
setSkillDAGStep(&session, "resolve_config_field")
advanceSkillDAGStep(&session, "resolve_config_field")
step, ok := currentSkillDAGStep(session)
if !ok {
t.Fatal("expected dag step")
}
if step.ID != "resolve_config_value" {
t.Fatalf("expected resolve_config_value, got %s", step.ID)
}
}

View File

@@ -1,67 +0,0 @@
package agent
import "testing"
func TestGetSkillDAGForStructuredActions(t *testing.T) {
tests := []struct {
skill string
action string
}{
{skill: "trader_management", action: "create"},
{skill: "trader_management", action: "update_bindings"},
{skill: "strategy_management", action: "update_config"},
{skill: "strategy_management", action: "update_prompt"},
{skill: "model_management", action: "update_status"},
{skill: "exchange_management", action: "update_name"},
}
for _, tt := range tests {
dag, ok := getSkillDAG(tt.skill, tt.action)
if !ok {
t.Fatalf("expected DAG for %s/%s", tt.skill, tt.action)
}
if dag.SkillName != tt.skill || dag.Action != tt.action {
t.Fatalf("unexpected dag identity: %+v", dag)
}
if len(dag.Steps) == 0 {
t.Fatalf("expected DAG steps for %s/%s", tt.skill, tt.action)
}
}
}
func TestStructuredDAGsHaveTerminalStep(t *testing.T) {
for _, dag := range listSkillDAGs() {
hasTerminal := false
for _, step := range dag.Steps {
if step.Terminal {
hasTerminal = true
break
}
}
if !hasTerminal {
t.Fatalf("expected terminal step for %s/%s", dag.SkillName, dag.Action)
}
}
}
func TestStrategyUpdateConfigDAGMatchesCurrentAtomicFlow(t *testing.T) {
dag, ok := getSkillDAG("strategy_management", "update_config")
if !ok {
t.Fatal("missing strategy update_config dag")
}
if len(dag.Steps) != 6 {
t.Fatalf("expected 6 steps, got %d", len(dag.Steps))
}
if dag.Steps[0].ID != "resolve_target" {
t.Fatalf("expected first step resolve_target, got %s", dag.Steps[0].ID)
}
if dag.Steps[1].ID != "resolve_config_field" {
t.Fatalf("expected second step resolve_config_field, got %s", dag.Steps[1].ID)
}
if dag.Steps[2].ID != "resolve_config_value" {
t.Fatalf("expected third step resolve_config_value, got %s", dag.Steps[2].ID)
}
if dag.Steps[5].ID != "execute_update" || !dag.Steps[5].Terminal {
t.Fatalf("expected final terminal execute step, got %+v", dag.Steps[5])
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,828 +0,0 @@
package agent
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"nofx/mcp"
)
func TestCreateTraderSkillCollectsMissingFieldsAndCreatesTrader(t *testing.T) {
a := newTestAgentWithStore(t)
modelResp := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"deepseek",
"enabled":true,
"api_key":"sk-test",
"custom_api_url":"https://api.deepseek.com/v1",
"custom_model_name":"deepseek-chat"
}`)
if strings.Contains(modelResp, `"error"`) {
t.Fatalf("failed to create model: %s", modelResp)
}
exchangeResp := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"okx",
"account_name":"主账户",
"enabled":true
}`)
if strings.Contains(exchangeResp, `"error"`) {
t.Fatalf("failed to create exchange: %s", exchangeResp)
}
strategyResp := a.toolManageStrategy("user-1", `{
"action":"create",
"name":"趋势策略",
"lang":"zh"
}`)
if strings.Contains(strategyResp, `"error"`) {
t.Fatalf("failed to create strategy: %s", strategyResp)
}
resp, err := a.thinkAndAct(context.Background(), "user-1", 1, "zh", "帮我创建一个交易员")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "还缺这些信息") || !strings.Contains(resp, "名称") {
t.Fatalf("expected missing-field prompt, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 1, "zh", "叫 波段一号")
if err != nil {
t.Fatalf("thinkAndAct() second turn error = %v", err)
}
if !strings.Contains(resp, "已创建交易员") || !strings.Contains(resp, "波段一号") {
t.Fatalf("expected trader creation confirmation, got %q", resp)
}
listResp := a.toolListTraders("user-1")
if !strings.Contains(listResp, "波段一号") {
t.Fatalf("expected created trader in list, got %s", listResp)
}
}
func TestCreateTraderSkillReportsAllMissingPrerequisitesAtOnce(t *testing.T) {
a := newTestAgentWithStore(t)
resp, err := a.thinkAndAct(context.Background(), "user-1", 11, "zh", "帮我创建一个交易员")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
for _, want := range []string{"名称", "交易所", "模型", "策略"} {
if !strings.Contains(resp, want) {
t.Fatalf("expected response to mention %q, got %q", want, resp)
}
}
for _, want := range []string{"当前还没有可用交易所配置", "当前还没有可用模型配置", "当前还没有可用策略"} {
if !strings.Contains(resp, want) {
t.Fatalf("expected response to mention prerequisite %q, got %q", want, resp)
}
}
}
func TestActiveSkillSessionYieldsToNewTopic(t *testing.T) {
a := newTestAgentWithStore(t)
_ = a.toolManageStrategy("user-1", `{
"action":"create",
"name":"测试策略",
"lang":"zh"
}`)
resp, err := a.thinkAndAct(context.Background(), "user-1", 13, "zh", "帮我创建一个交易员")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "还缺这些信息") {
t.Fatalf("expected trader creation flow prompt, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 13, "zh", "列出我当前的策略")
if err != nil {
t.Fatalf("thinkAndAct() interrupt error = %v", err)
}
if !strings.Contains(resp, "当前策略") || !strings.Contains(resp, "测试策略") {
t.Fatalf("expected new topic to be handled, got %q", resp)
}
if a.hasActiveSkillSession(13) {
t.Fatal("expected skill session to be cleared after interruption")
}
}
func TestCreateTraderSkillRequestsStartConfirmation(t *testing.T) {
a := newTestAgentWithStore(t)
_ = a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"openai",
"enabled":true,
"api_key":"sk-test",
"custom_api_url":"https://api.openai.com/v1",
"custom_model_name":"gpt-5"
}`)
_ = a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"binance",
"account_name":"Main",
"enabled":true
}`)
_ = a.toolManageStrategy("user-1", `{
"action":"create",
"name":"保守策略",
"lang":"zh"
}`)
resp, err := a.thinkAndAct(context.Background(), "user-1", 2, "zh", "创建一个叫“实盘一号”的交易员并启动")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "高风险动作") || !strings.Contains(resp, "确认") {
t.Fatalf("expected start confirmation prompt, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 2, "zh", "先不用")
if err != nil {
t.Fatalf("thinkAndAct() confirmation error = %v", err)
}
if !strings.Contains(resp, "已创建交易员") || strings.Contains(resp, "已创建并启动") {
t.Fatalf("expected create-without-start response, got %q", resp)
}
}
func TestModelDiagnosisSkillHandledWithoutAIClient(t *testing.T) {
a := newTestAgentWithStore(t)
resp, err := a.thinkAndAct(context.Background(), "user-1", 3, "zh", "为什么我的模型配置失败了")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "模型配置") {
t.Fatalf("expected model diagnosis response, got %q", resp)
}
}
func TestExchangeDiagnosisSkillHandledWithoutAIClient(t *testing.T) {
a := newTestAgentWithStore(t)
resp, err := a.thinkAndAct(context.Background(), "user-1", 4, "zh", "交易所 API 报 invalid signature 怎么办")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "invalid signature") && !strings.Contains(resp, "签名") {
t.Fatalf("expected exchange diagnosis response, got %q", resp)
}
}
func TestExchangeManagementCreateAndQuerySkill(t *testing.T) {
a := newTestAgentWithStore(t)
resp, err := a.thinkAndAct(context.Background(), "user-1", 5, "zh", "帮我创建一个 OKX 交易所配置")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "已创建交易所配置") {
t.Fatalf("expected exchange create response, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 5, "zh", "列出我的交易所配置")
if err != nil {
t.Fatalf("thinkAndAct() query error = %v", err)
}
if !strings.Contains(resp, "当前交易所配置") && !strings.Contains(resp, "Default") {
t.Fatalf("expected exchange query response, got %q", resp)
}
}
func TestModelManagementCreateSkill(t *testing.T) {
a := newTestAgentWithStore(t)
resp, err := a.thinkAndAct(context.Background(), "user-1", 6, "zh", "帮我创建一个 DeepSeek 模型配置")
if err != nil {
t.Fatalf("thinkAndAct() error = %v", err)
}
if !strings.Contains(resp, "已创建模型配置") {
t.Fatalf("expected model create response, got %q", resp)
}
}
func TestStrategyManagementCreateAndActivateSkill(t *testing.T) {
a := newTestAgentWithStore(t)
resp, err := a.thinkAndAct(context.Background(), "user-1", 7, "zh", "创建一个叫“趋势策略B”的策略")
if err != nil {
t.Fatalf("thinkAndAct() create error = %v", err)
}
if !strings.Contains(resp, "已创建策略") {
t.Fatalf("expected strategy create response, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 7, "zh", "激活趋势策略B")
if err != nil {
t.Fatalf("thinkAndAct() activate error = %v", err)
}
if !strings.Contains(resp, "已激活策略") {
t.Fatalf("expected strategy activate response, got %q", resp)
}
}
func TestStrategyManagementQueryCanExplainStrategyDetails(t *testing.T) {
a := newTestAgentWithStore(t)
resp, err := a.thinkAndAct(context.Background(), "user-1", 12, "zh", "创建一个叫“激进的”的策略")
if err != nil {
t.Fatalf("thinkAndAct() create error = %v", err)
}
if !strings.Contains(resp, "已创建策略") {
t.Fatalf("expected strategy create response, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 12, "zh", "这个策略里面的参数和prompt分别是什么样的")
if err != nil {
t.Fatalf("thinkAndAct() detail query error = %v", err)
}
for _, want := range []string{"策略“激进的”概览", "K线周期", "仓位风险", "Prompt"} {
if !strings.Contains(resp, want) {
t.Fatalf("expected response to mention %q, got %q", want, resp)
}
}
}
func TestTraderManagementQueryAndDiagnosisSkill(t *testing.T) {
a := newTestAgentWithStore(t)
modelResp := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"openai",
"enabled":true,
"api_key":"sk-test",
"custom_api_url":"https://api.openai.com/v1",
"custom_model_name":"gpt-5"
}`)
var modelCreated struct {
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(modelResp), &modelCreated); err != nil {
t.Fatalf("unmarshal model response: %v", err)
}
exchangeResp := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"binance",
"account_name":"Main",
"enabled":true
}`)
var exchangeCreated struct {
Exchange safeExchangeToolConfig `json:"exchange"`
}
if err := json.Unmarshal([]byte(exchangeResp), &exchangeCreated); err != nil {
t.Fatalf("unmarshal exchange response: %v", err)
}
_ = a.toolManageStrategy("user-1", `{
"action":"create",
"name":"测试策略",
"lang":"zh"
}`)
_ = a.toolManageTrader("user-1", `{
"action":"create",
"name":"测试交易员",
"ai_model_id":"`+modelCreated.Model.ID+`",
"exchange_id":"`+exchangeCreated.Exchange.ID+`",
"strategy_id":""
}`)
resp, err := a.thinkAndAct(context.Background(), "user-1", 8, "zh", "查看我的交易员")
if err != nil {
t.Fatalf("thinkAndAct() query error = %v", err)
}
if !strings.Contains(resp, "当前交易员") && !strings.Contains(resp, "测试交易员") {
t.Fatalf("expected trader query response, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 8, "zh", "为什么我的交易员不交易")
if err != nil {
t.Fatalf("thinkAndAct() diagnosis error = %v", err)
}
if !strings.Contains(resp, "交易员运行诊断") {
t.Fatalf("expected trader diagnosis response, got %q", resp)
}
}
func TestExchangeManagementAtomicUpdates(t *testing.T) {
a := newTestAgentWithStore(t)
createResp := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"okx",
"account_name":"主账户",
"enabled":true
}`)
var created struct {
Exchange safeExchangeToolConfig `json:"exchange"`
}
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
t.Fatalf("unmarshal exchange response: %v", err)
}
resp, err := a.thinkAndAct(context.Background(), "user-1", 14, "zh", "更新交易所,把主账户改名为备用账户")
if err != nil {
t.Fatalf("rename exchange error = %v", err)
}
if !strings.Contains(resp, "已更新交易所配置") {
t.Fatalf("expected exchange update response, got %q", resp)
}
raw := a.toolGetExchangeConfigs("user-1")
if !strings.Contains(raw, "备用账户") {
t.Fatalf("expected renamed exchange in list, got %s", raw)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 14, "zh", "禁用这个交易所配置")
if err != nil {
t.Fatalf("disable exchange error = %v", err)
}
if !strings.Contains(resp, "已更新交易所配置") {
t.Fatalf("expected exchange status update response, got %q", resp)
}
raw = a.toolGetExchangeConfigs("user-1")
if strings.Contains(raw, `"enabled":true`) && strings.Contains(raw, "备用账户") {
t.Fatalf("expected exchange to be disabled, got %s", raw)
}
}
func TestModelManagementAtomicUpdates(t *testing.T) {
a := newTestAgentWithStore(t)
createResp := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"deepseek",
"enabled":true,
"custom_api_url":"https://api.deepseek.com/v1",
"custom_model_name":"deepseek-chat"
}`)
var created struct {
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
t.Fatalf("unmarshal model response: %v", err)
}
resp, err := a.thinkAndAct(context.Background(), "user-1", 15, "zh", "更新模型,把模型名称改成 deepseek-reasoner")
if err != nil {
t.Fatalf("rename model error = %v", err)
}
if !strings.Contains(resp, "已更新模型配置") {
t.Fatalf("expected model update response, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 15, "zh", "更新模型,把接口地址改成 https://api.deepseek.com/beta")
if err != nil {
t.Fatalf("update model endpoint error = %v", err)
}
if !strings.Contains(resp, "已更新模型配置") {
t.Fatalf("expected model endpoint update response, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 15, "zh", "禁用这个模型配置")
if err != nil {
t.Fatalf("disable model error = %v", err)
}
if !strings.Contains(resp, "已更新模型配置") {
t.Fatalf("expected model status update response, got %q", resp)
}
raw := a.toolGetModelConfigs("user-1")
if !strings.Contains(raw, "deepseek-reasoner") || !strings.Contains(raw, "https://api.deepseek.com/beta") {
t.Fatalf("expected updated model fields, got %s", raw)
}
if strings.Contains(raw, `"enabled":true`) && strings.Contains(raw, created.Model.ID) {
t.Fatalf("expected model to be disabled, got %s", raw)
}
}
func TestStrategyManagementAtomicUpdates(t *testing.T) {
a := newTestAgentWithStore(t)
resp, err := a.thinkAndAct(context.Background(), "user-1", 16, "zh", "创建一个叫“激进策略C”的策略")
if err != nil {
t.Fatalf("create strategy error = %v", err)
}
if !strings.Contains(resp, "已创建策略") {
t.Fatalf("expected strategy create response, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 16, "zh", "更新这个策略的prompt把提示词改成“优先观察BTC和ETH信号不一致时不要开仓”")
if err != nil {
t.Fatalf("update strategy prompt error = %v", err)
}
if !strings.Contains(resp, "已更新策略 prompt") {
t.Fatalf("expected strategy prompt update response, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 16, "zh", "更新这个策略参数把最大持仓改成2最低置信度改成80主周期改成15m并使用15m 1h 4h")
if err != nil {
t.Fatalf("update strategy config error = %v", err)
}
if !strings.Contains(resp, "已更新策略参数") {
t.Fatalf("expected strategy config update response, got %q", resp)
}
listRaw := a.toolGetStrategies("user-1")
if !strings.Contains(listRaw, "优先观察BTC和ETH") || !strings.Contains(listRaw, `"max_positions":2`) || !strings.Contains(listRaw, `"min_confidence":80`) || !strings.Contains(listRaw, `"primary_timeframe":"15m"`) {
t.Fatalf("expected updated strategy config, got %s", listRaw)
}
}
func TestTraderManagementAtomicBindingUpdate(t *testing.T) {
a := newTestAgentWithStore(t)
modelOpenAI := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"openai",
"enabled":true,
"custom_api_url":"https://api.openai.com/v1",
"custom_model_name":"gpt-5-mini"
}`)
var openAI struct {
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(modelOpenAI), &openAI); err != nil {
t.Fatalf("unmarshal openai model: %v", err)
}
modelDeepSeek := a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"deepseek",
"enabled":true,
"custom_api_url":"https://api.deepseek.com/v1",
"custom_model_name":"deepseek-chat"
}`)
var deepSeek struct {
Model safeModelToolConfig `json:"model"`
}
if err := json.Unmarshal([]byte(modelDeepSeek), &deepSeek); err != nil {
t.Fatalf("unmarshal deepseek model: %v", err)
}
exchangeBinance := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"binance",
"account_name":"Binance 主账户",
"enabled":true
}`)
var binance struct {
Exchange safeExchangeToolConfig `json:"exchange"`
}
if err := json.Unmarshal([]byte(exchangeBinance), &binance); err != nil {
t.Fatalf("unmarshal binance exchange: %v", err)
}
exchangeOKX := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"okx",
"account_name":"OKX 主账户",
"enabled":true
}`)
var okx struct {
Exchange safeExchangeToolConfig `json:"exchange"`
}
if err := json.Unmarshal([]byte(exchangeOKX), &okx); err != nil {
t.Fatalf("unmarshal okx exchange: %v", err)
}
strategyA := a.toolManageStrategy("user-1", `{"action":"create","name":"策略A","lang":"zh"}`)
var stA struct {
Strategy safeStrategyToolConfig `json:"strategy"`
}
if err := json.Unmarshal([]byte(strategyA), &stA); err != nil {
t.Fatalf("unmarshal strategy A: %v", err)
}
strategyB := a.toolManageStrategy("user-1", `{"action":"create","name":"策略B","lang":"zh"}`)
var stB struct {
Strategy safeStrategyToolConfig `json:"strategy"`
}
if err := json.Unmarshal([]byte(strategyB), &stB); err != nil {
t.Fatalf("unmarshal strategy B: %v", err)
}
createTrader := a.toolManageTrader("user-1", `{
"action":"create",
"name":"实盘一号",
"ai_model_id":"`+openAI.Model.ID+`",
"exchange_id":"`+binance.Exchange.ID+`",
"strategy_id":"`+stA.Strategy.ID+`"
}`)
var trader struct {
Trader safeTraderToolConfig `json:"trader"`
}
if err := json.Unmarshal([]byte(createTrader), &trader); err != nil {
t.Fatalf("unmarshal trader: %v", err)
}
resp, err := a.thinkAndAct(context.Background(), "user-1", 17, "zh", "更新交易员绑定,把实盘一号换成 deepseek-chat、OKX 主账户 和 策略B")
if err != nil {
t.Fatalf("update trader bindings error = %v", err)
}
if !strings.Contains(resp, "已更新交易员绑定") {
t.Fatalf("expected trader binding update response, got %q", resp)
}
listRaw := a.toolListTraders("user-1")
if !strings.Contains(listRaw, deepSeek.Model.ID) || !strings.Contains(listRaw, okx.Exchange.ID) || !strings.Contains(listRaw, stB.Strategy.ID) {
t.Fatalf("expected trader bindings to change, got %s", listRaw)
}
}
func TestStrategyManagementDeleteAllUserStrategies(t *testing.T) {
a := newTestAgentWithStore(t)
for _, name := range []string{"趋势策略A", "趋势策略B"} {
resp := a.toolManageStrategy("user-1", `{
"action":"create",
"name":"`+name+`",
"lang":"zh"
}`)
if strings.Contains(resp, `"error"`) {
t.Fatalf("failed to create strategy %q: %s", name, resp)
}
}
resp, err := a.thinkAndAct(context.Background(), "user-1", 21, "zh", "现在把所有的策略全部删除")
if err != nil {
t.Fatalf("thinkAndAct() bulk delete start error = %v", err)
}
if !strings.Contains(resp, "确认") || !strings.Contains(resp, "全部自定义策略") {
t.Fatalf("expected bulk delete confirmation, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 21, "zh", "确认")
if err != nil {
t.Fatalf("thinkAndAct() bulk delete confirm error = %v", err)
}
if !strings.Contains(resp, "成功删除 2 个") {
t.Fatalf("expected bulk delete success summary, got %q", resp)
}
listResp := a.toolGetStrategies("user-1")
if strings.Contains(listResp, "趋势策略A") || strings.Contains(listResp, "趋势策略B") {
t.Fatalf("expected created strategies to be deleted, got %s", listResp)
}
}
func TestCreateTraderSkillRejectsDisabledExchangeWithClearPrompt(t *testing.T) {
a := newTestAgentWithStore(t)
_ = a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"deepseek",
"enabled":true,
"api_key":"sk-test",
"custom_api_url":"https://api.deepseek.com/v1",
"custom_model_name":"deepseek-chat"
}`)
enabledExchange := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"okx",
"account_name":"test",
"enabled":true
}`)
if strings.Contains(enabledExchange, `"error"`) {
t.Fatalf("failed to create enabled exchange: %s", enabledExchange)
}
anotherEnabledExchange := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"okx",
"account_name":"lky",
"enabled":true
}`)
if strings.Contains(anotherEnabledExchange, `"error"`) {
t.Fatalf("failed to create second enabled exchange: %s", anotherEnabledExchange)
}
disabledExchange := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"okx",
"account_name":"new",
"enabled":false
}`)
if strings.Contains(disabledExchange, `"error"`) {
t.Fatalf("failed to create disabled exchange: %s", disabledExchange)
}
_ = a.toolManageStrategy("user-1", `{"action":"create","name":"激进","lang":"zh"}`)
resp, err := a.thinkAndAct(context.Background(), "user-1", 24, "zh", "给我创建一个trader")
if err != nil {
t.Fatalf("create trader start error = %v", err)
}
if !strings.Contains(resp, "new已禁用") {
t.Fatalf("expected disabled exchange to be labelled, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 24, "zh", "名称叫test交易所用new、策略用激进")
if err != nil {
t.Fatalf("disabled exchange selection error = %v", err)
}
if !strings.Contains(resp, "当前已禁用") {
t.Fatalf("expected disabled exchange warning, got %q", resp)
}
}
func TestCancelReplyExitsExchangeUpdateFlow(t *testing.T) {
a := newTestAgentWithStore(t)
_ = a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"deepseek",
"enabled":true,
"api_key":"sk-test",
"custom_api_url":"https://api.deepseek.com/v1",
"custom_model_name":"deepseek-chat"
}`)
exchangeResp := a.toolManageExchangeConfig("user-1", `{
"action":"create",
"exchange_type":"okx",
"account_name":"test",
"enabled":true
}`)
if strings.Contains(exchangeResp, `"error"`) {
t.Fatalf("failed to create exchange: %s", exchangeResp)
}
resp, err := a.thinkAndAct(context.Background(), "user-1", 25, "zh", "把test这个交易所改一下")
if err != nil {
t.Fatalf("enter exchange update flow error = %v", err)
}
if !strings.Contains(resp, "请告诉我你要改什么") {
t.Fatalf("expected exchange update prompt, got %q", resp)
}
resp, err = a.thinkAndAct(context.Background(), "user-1", 25, "zh", "不改")
if err != nil {
t.Fatalf("cancel exchange flow error = %v", err)
}
if !strings.Contains(resp, "已取消当前流程") {
t.Fatalf("expected flow cancellation, got %q", resp)
}
}
func TestClassifySkillSessionInputInterruptsOnDeflection(t *testing.T) {
session := skillSession{Name: "exchange_management", Action: "update"}
a := &Agent{}
if got := a.classifySkillSessionInput(context.Background(), 0, "zh", session, "你能帮我看下报错吗"); got != "interrupt" {
t.Fatalf("expected diagnosis deflection to interrupt current skill flow, got %q", got)
}
if got := a.classifySkillSessionInput(context.Background(), 0, "zh", session, "换话题了大哥"); got != "cancel" {
t.Fatalf("expected topic shift to cancel current skill flow, got %q", got)
}
}
type skillSessionClassifierAIClient struct {
lastSystemPrompt string
lastUserPrompt string
response string
}
func (c *skillSessionClassifierAIClient) SetAPIKey(string, string, string) {}
func (c *skillSessionClassifierAIClient) SetTimeout(time.Duration) {}
func (c *skillSessionClassifierAIClient) CallWithMessages(string, string) (string, error) {
return "", errors.New("unexpected CallWithMessages")
}
func (c *skillSessionClassifierAIClient) CallWithRequest(req *mcp.Request) (string, error) {
if len(req.Messages) > 0 {
c.lastSystemPrompt = req.Messages[0].Content
}
if len(req.Messages) > 1 {
c.lastUserPrompt = req.Messages[1].Content
}
return c.response, nil
}
func (c *skillSessionClassifierAIClient) CallWithRequestStream(*mcp.Request, func(string)) (string, error) {
return "", errors.New("unexpected CallWithRequestStream")
}
func (c *skillSessionClassifierAIClient) CallWithRequestFull(*mcp.Request) (*mcp.LLMResponse, error) {
return nil, errors.New("unexpected CallWithRequestFull")
}
func TestClassifySkillSessionInputUsesSlotExpectationWithoutLLM(t *testing.T) {
client := &skillSessionClassifierAIClient{response: `{"decision":"interrupt"}`}
a := &Agent{aiClient: client}
session := skillSession{
Name: "strategy_management",
Action: "update_config",
Fields: map[string]string{
skillDAGStepField: "resolve_config_value",
"config_field": "min_confidence",
},
}
if got := a.classifySkillSessionInput(context.Background(), 0, "zh", session, "70"); got != "continue" {
t.Fatalf("expected numeric slot fill to continue, got %q", got)
}
if client.lastSystemPrompt != "" {
t.Fatalf("expected no LLM call for direct slot expectation, got prompt %q", client.lastSystemPrompt)
}
}
func TestClassifySkillSessionInputUsesLLMOnlyForAmbiguousDeflection(t *testing.T) {
client := &skillSessionClassifierAIClient{response: `{"decision":"interrupt"}`}
a := &Agent{
aiClient: client,
history: newChatHistory(10),
}
session := skillSession{
Name: "exchange_management",
Action: "update",
Fields: map[string]string{
skillDAGStepField: "collect_account_name",
},
}
if got := a.classifySkillSessionInput(context.Background(), 0, "zh", session, "你能帮我看下报错吗"); got != "interrupt" {
t.Fatalf("expected ambiguous deflection to interrupt, got %q", got)
}
if !strings.Contains(client.lastSystemPrompt, "classify one user message while a NOFXi structured management flow is active") {
t.Fatalf("expected LLM classifier prompt, got %q", client.lastSystemPrompt)
}
}
func TestClassifySkillSessionInputUsesLLMForUnmatchedActiveSessionInput(t *testing.T) {
client := &skillSessionClassifierAIClient{response: `{"decision":"continue"}`}
a := &Agent{
aiClient: client,
history: newChatHistory(10),
}
session := skillSession{
Name: "model_management",
Action: "create",
Fields: map[string]string{
skillDAGStepField: "collect_optional_fields",
"provider": "openai",
},
}
if got := a.classifySkillSessionInput(context.Background(), 0, "zh", session, "新增一个"); got != "continue" {
t.Fatalf("expected unmatched active-session input to follow LLM decision, got %q", got)
}
if !strings.Contains(client.lastSystemPrompt, "classify one user message while a NOFXi structured management flow is active") {
t.Fatalf("expected LLM classifier prompt, got %q", client.lastSystemPrompt)
}
}
func TestStrategyManagementCanDescribeDefaultConfig(t *testing.T) {
a := newTestAgentWithStore(t)
_ = a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"deepseek",
"enabled":true,
"api_key":"sk-test",
"custom_api_url":"https://api.deepseek.com/v1",
"custom_model_name":"deepseek-chat"
}`)
resp, err := a.thinkAndAct(context.Background(), "user-1", 22, "zh", "看一下默认配置")
if err != nil {
t.Fatalf("thinkAndAct() default config error = %v", err)
}
if !strings.Contains(resp, "默认策略模板") || !strings.Contains(resp, "最低置信度") {
t.Fatalf("expected default strategy config response, got %q", resp)
}
}
func TestStrategyManagementSupportsMultiFieldConfigUpdate(t *testing.T) {
a := newTestAgentWithStore(t)
_ = a.toolManageModelConfig("user-1", `{
"action":"create",
"provider":"deepseek",
"enabled":true,
"api_key":"sk-test",
"custom_api_url":"https://api.deepseek.com/v1",
"custom_model_name":"deepseek-chat"
}`)
createResp := a.toolManageStrategy("user-1", `{
"action":"create",
"name":"趋势策略A",
"lang":"zh"
}`)
if strings.Contains(createResp, `"error"`) {
t.Fatalf("failed to create strategy: %s", createResp)
}
resp, err := a.thinkAndAct(context.Background(), "user-1", 23, "zh", "把趋势策略A的最小置信度改成70核心指标都全选")
if err != nil {
t.Fatalf("thinkAndAct() multi-field update error = %v", err)
}
if !strings.Contains(resp, "最小置信度") || !strings.Contains(resp, "EMA") {
t.Fatalf("expected multi-field update confirmation, got %q", resp)
}
strategiesRaw := a.toolGetStrategies("user-1")
if !strings.Contains(strategiesRaw, `"min_confidence":70`) ||
!strings.Contains(strategiesRaw, `"enable_ema":true`) ||
!strings.Contains(strategiesRaw, `"enable_macd":true`) ||
!strings.Contains(strategiesRaw, `"enable_rsi":true`) ||
!strings.Contains(strategiesRaw, `"enable_atr":true`) ||
!strings.Contains(strategiesRaw, `"enable_boll":true`) {
t.Fatalf("expected strategy config to include updated confidence and indicators, got %s", strategiesRaw)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,931 +0,0 @@
package agent
import (
"encoding/json"
"fmt"
"regexp"
"sort"
"strings"
"nofx/store"
)
var urlPattern = regexp.MustCompile(`https://[^\s"'<>]+`)
func detectTraderManagementIntent(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" {
return false
}
return containsAny(lower, []string{"交易员", "trader", "agent"}) &&
containsAny(lower, []string{"修改", "编辑", "更新", "改", "改一下", "删除", "删了", "启动", "停止", "查看", "查询", "列出", "rename", "update", "delete", "start", "stop", "list", "show"})
}
func detectExchangeManagementIntent(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" {
return false
}
return containsAny(lower, []string{"交易所", "exchange", "okx", "binance", "bybit", "gate", "kucoin", "hyperliquid"}) &&
containsAny(lower, []string{"创建", "新建", "修改", "编辑", "更新", "改", "改一下", "删除", "删了", "查询", "查看", "列出", "启用", "禁用", "改名", "rename", "create", "update", "delete", "list", "show", "enable", "disable"})
}
func detectModelManagementIntent(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" {
return false
}
return containsAny(lower, []string{"模型", "model", "provider", "deepseek", "openai", "claude", "gemini", "qwen", "kimi", "grok", "minimax"}) &&
containsAny(lower, []string{"创建", "新建", "修改", "编辑", "更新", "改", "改一下", "删除", "删了", "查询", "查看", "列出", "启用", "禁用", "改名", "rename", "create", "update", "delete", "list", "show", "enable", "disable"})
}
func detectStrategyManagementIntent(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" {
return false
}
if wantsDefaultStrategyConfig(text) {
return true
}
return containsAny(lower, []string{"策略", "strategy"}) &&
containsAny(lower, []string{"创建", "新建", "修改", "编辑", "更新", "改", "改一下", "改成", "改为", "删除", "删了", "查询", "查看", "列出", "激活", "复制", "参数", "配置", "详情", "详细", "prompt", "提示词", "什么样", "怎么样", "create", "update", "delete", "list", "show", "activate", "duplicate", "detail", "details", "config", "configuration", "parameter", "prompt", "what kind"})
}
func detectTraderDiagnosisSkill(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
return containsAny(lower, []string{"交易员", "trader"}) &&
containsAny(lower, []string{"启动失败", "不交易", "没开仓", "无法启动", "异常", "失败", "diagnose", "error", "not trading"})
}
func detectStrategyDiagnosisSkill(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
return containsAny(lower, []string{"策略", "strategy", "prompt"}) &&
containsAny(lower, []string{"不生效", "没生效", "异常", "失败", "不一致", "失效", "diagnose", "error"})
}
func detectManagementAction(text string, domain string) string {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" {
return ""
}
hasUpdateVerb := containsAny(lower, []string{"修改", "编辑", "更新", "改", "rename", "update", "切换", "换成", "换到"})
switch {
case containsAny(lower, []string{"删除", "删掉", "删了", "remove", "delete"}):
return "delete"
case containsAny(lower, []string{"启动", "开始", "run", "start"}) && domain == "trader":
return "start"
case containsAny(lower, []string{"停止", "停掉", "stop", "pause"}) && domain == "trader":
return "stop"
case containsAny(lower, []string{"激活", "activate"}) && domain == "strategy":
return "activate"
case containsAny(lower, []string{"复制", "duplicate"}) && domain == "strategy":
return "duplicate"
case containsAny(lower, []string{"改名", "重命名", "rename"}):
return "update_name"
case domain == "trader" && containsAny(lower, []string{"换模型", "换交易所", "换策略", "绑定", "切换模型", "切换交易所", "切换策略"}):
return "update_bindings"
case (domain == "exchange" || domain == "model") && containsAny(lower, []string{"启用", "禁用", "enable", "disable"}):
return "update_status"
case domain == "model" && hasUpdateVerb && containsAny(lower, []string{"url", "endpoint", "地址", "接口"}):
return "update_endpoint"
case domain == "strategy" && hasUpdateVerb && containsAny(lower, []string{"prompt", "提示词"}):
return "update_prompt"
case domain == "strategy" && hasUpdateVerb && containsAny(lower, []string{
"参数", "配置", "config", "configuration", "parameter",
"最大持仓", "最小置信度", "最低置信度", "主周期", "多周期", "时间框架",
"btc/eth杠杆", "btc eth杠杆", "山寨币杠杆",
"核心指标", "ema", "macd", "rsi", "atr", "boll", "bollinger", "布林",
}):
return "update_config"
case containsAny(lower, []string{"修改", "编辑", "更新", "改", "rename", "update"}):
return "update"
case domain == "trader" && containsAny(lower, []string{"运行中的", "在跑", "running"}):
return "query_running"
case !containsAny(lower, []string{"创建", "新建", "create", "new"}) &&
containsAny(lower, []string{"详情", "详细", "prompt", "提示词", "什么样", "怎么样", "detail", "details", "what kind"}):
return "query_detail"
case containsAny(lower, []string{"查询", "查看", "列出", "list", "show", "有哪些"}):
return "query_list"
case containsAny(lower, []string{"创建", "新建", "加一个", "create", "new"}):
return "create"
default:
return ""
}
}
func exchangeTypeFromText(text string) string {
lower := strings.ToLower(text)
candidates := []string{"binance", "okx", "bybit", "gate", "kucoin", "hyperliquid", "aster", "lighter"}
for _, candidate := range candidates {
if strings.Contains(lower, candidate) {
return candidate
}
}
switch {
case strings.Contains(text, "币安"):
return "binance"
case strings.Contains(text, "欧易"):
return "okx"
case strings.Contains(text, "库币"):
return "kucoin"
default:
return ""
}
}
func providerFromText(text string) string {
lower := strings.ToLower(text)
candidates := []string{"openai", "deepseek", "claude", "gemini", "qwen", "kimi", "grok", "minimax"}
for _, candidate := range candidates {
if strings.Contains(lower, candidate) {
return candidate
}
}
if strings.Contains(text, "通义") {
return "qwen"
}
return ""
}
func extractURL(text string) string {
return strings.TrimSpace(urlPattern.FindString(text))
}
func extractPostKeywordName(text string, keywords []string) string {
trimmed := strings.TrimSpace(text)
for _, keyword := range keywords {
if idx := strings.Index(trimmed, keyword); idx >= 0 {
name := strings.TrimSpace(trimmed[idx+len(keyword):])
name = strings.Trim(name, "“”\"': ")
if name != "" && len([]rune(name)) <= 50 {
return name
}
}
}
return ""
}
func setField(session *skillSession, key, value string) {
ensureSkillFields(session)
value = strings.TrimSpace(value)
if value == "" {
return
}
session.Fields[key] = value
}
func fieldValue(session skillSession, key string) string {
if session.Fields == nil {
return ""
}
return strings.TrimSpace(session.Fields[key])
}
func textMeansAllTargets(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" {
return false
}
return containsAny(lower, []string{
"全部", "所有", "全都", "全部策略", "所有策略",
"all", "all strategies", "every strategy",
})
}
func supportsBulkTargetSelection(skillName, action string) bool {
return skillName == "strategy_management" && action == "delete"
}
func resolveTargetFromText(text string, options []traderSkillOption, existing *EntityReference) *EntityReference {
if existing != nil && (existing.ID != "" || existing.Name != "") {
return existing
}
if match := pickMentionedOption(text, options); match != nil {
return &EntityReference{ID: match.ID, Name: match.Name}
}
if choice := choosePreferredOption(options); choice != nil {
return &EntityReference{ID: choice.ID, Name: choice.Name}
}
return nil
}
func (a *Agent) handleTraderManagementSkill(storeUserID string, userID int64, lang, text string, session skillSession) (string, bool) {
action := detectManagementAction(text, "trader")
if session.Name == "trader_management" && session.Action != "" {
action = session.Action
}
if action == "" || action == "create" {
return "", false
}
if action == "query_running" {
answer := formatReadFastPathResponse(lang, "list_traders", a.toolListTraders(storeUserID))
return applyTraderQueryFilter(lang, answer, a.toolListTraders(storeUserID), "running_only"), true
}
if action == "query_detail" {
options := a.loadTraderOptions(storeUserID)
target := resolveTargetFromText(text, options, session.TargetRef)
if detail, ok := a.describeTrader(storeUserID, lang, target); ok {
return detail, true
}
return formatReadFastPathResponse(lang, "list_traders", a.toolListTraders(storeUserID)), true
}
return a.handleSimpleEntitySkill(storeUserID, userID, lang, text, session, "trader_management", action, a.loadTraderOptions(storeUserID))
}
func (a *Agent) handleExchangeManagementSkill(storeUserID string, userID int64, lang, text string, session skillSession) (string, bool) {
action := detectManagementAction(text, "exchange")
if session.Name == "exchange_management" && session.Action != "" {
action = session.Action
}
if action == "" {
return "", false
}
options := a.loadExchangeOptions(storeUserID)
switch action {
case "query_list":
return formatReadFastPathResponse(lang, "get_exchange_configs", a.toolGetExchangeConfigs(storeUserID)), true
case "query_detail":
target := resolveTargetFromText(text, options, session.TargetRef)
if detail, ok := a.describeExchange(storeUserID, lang, target); ok {
return detail, true
}
return formatReadFastPathResponse(lang, "get_exchange_configs", a.toolGetExchangeConfigs(storeUserID)), true
case "create":
return a.handleExchangeCreateSkill(storeUserID, userID, lang, text, session), true
default:
return a.handleSimpleEntitySkill(storeUserID, userID, lang, text, session, "exchange_management", action, options)
}
}
func (a *Agent) handleModelManagementSkill(storeUserID string, userID int64, lang, text string, session skillSession) (string, bool) {
action := detectManagementAction(text, "model")
if session.Name == "model_management" && session.Action != "" {
action = session.Action
}
if action == "" {
return "", false
}
options := a.loadEnabledModelOptions(storeUserID)
switch action {
case "query_list":
return formatReadFastPathResponse(lang, "get_model_configs", a.toolGetModelConfigs(storeUserID)), true
case "query_detail":
target := resolveTargetFromText(text, options, session.TargetRef)
if detail, ok := a.describeModel(storeUserID, lang, target); ok {
return detail, true
}
return formatReadFastPathResponse(lang, "get_model_configs", a.toolGetModelConfigs(storeUserID)), true
case "create":
return a.handleModelCreateSkill(storeUserID, userID, lang, text, session), true
default:
return a.handleSimpleEntitySkill(storeUserID, userID, lang, text, session, "model_management", action, options)
}
}
func (a *Agent) handleStrategyManagementSkill(storeUserID string, userID int64, lang, text string, session skillSession) (string, bool) {
action := detectManagementAction(text, "strategy")
if session.Name == "strategy_management" && session.Action != "" {
action = session.Action
}
if action == "" && wantsStrategyDetails(text) {
action = "query_detail"
}
if action == "" {
return "", false
}
options := a.loadStrategyOptions(storeUserID)
switch action {
case "query_detail":
if wantsDefaultStrategyConfig(text) {
return a.describeDefaultStrategyConfig(lang), true
}
target := resolveTargetFromText(text, options, session.TargetRef)
if detail, ok := a.describeStrategy(storeUserID, lang, target); ok {
return detail, true
}
return formatReadFastPathResponse(lang, "get_strategies", a.toolGetStrategies(storeUserID)), true
case "query_list":
return formatReadFastPathResponse(lang, "get_strategies", a.toolGetStrategies(storeUserID)), true
case "create":
return a.handleStrategyCreateSkill(storeUserID, userID, lang, text, session), true
default:
return a.handleSimpleEntitySkill(storeUserID, userID, lang, text, session, "strategy_management", action, options)
}
}
func wantsStrategyDetails(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" {
return false
}
return containsAny(lower, []string{
"什么样", "怎么样", "详情", "详细", "参数", "配置", "prompt", "提示词",
"what kind", "details", "detail", "config", "configuration", "parameter", "prompt",
})
}
func wantsDefaultStrategyConfig(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" {
return false
}
return containsAny(lower, []string{
"默认配置", "默认策略", "默认模板", "模板配置",
"default config", "default strategy", "default template",
})
}
func (a *Agent) describeStrategy(storeUserID, lang string, target *EntityReference) (string, bool) {
if a.store == nil {
return "", false
}
var strategy *store.Strategy
var err error
if target != nil && strings.TrimSpace(target.ID) != "" {
strategy, err = a.store.Strategy().Get(storeUserID, strings.TrimSpace(target.ID))
} else if target != nil && strings.TrimSpace(target.Name) != "" {
strategies, listErr := a.store.Strategy().List(storeUserID)
if listErr != nil {
return "", false
}
for _, item := range strategies {
if item != nil && strings.EqualFold(strings.TrimSpace(item.Name), strings.TrimSpace(target.Name)) {
strategy = item
break
}
}
} else {
strategies, listErr := a.store.Strategy().List(storeUserID)
if listErr != nil || len(strategies) != 1 {
return "", false
}
strategy = strategies[0]
}
if err != nil || strategy == nil {
return "", false
}
var cfg store.StrategyConfig
if strings.TrimSpace(strategy.Config) != "" {
_ = json.Unmarshal([]byte(strategy.Config), &cfg)
}
return formatStrategyDetailResponse(lang, strategy, cfg), true
}
func formatStrategyDetailResponse(lang string, strategy *store.Strategy, cfg store.StrategyConfig) string {
name := strings.TrimSpace(strategy.Name)
if name == "" {
name = strings.TrimSpace(strategy.ID)
}
sourceBits := make([]string, 0, 4)
if strings.TrimSpace(cfg.CoinSource.SourceType) != "" {
sourceBits = append(sourceBits, cfg.CoinSource.SourceType)
}
if cfg.CoinSource.UseAI500 {
sourceBits = append(sourceBits, fmt.Sprintf("AI500=%d", cfg.CoinSource.AI500Limit))
}
if cfg.CoinSource.UseOITop {
sourceBits = append(sourceBits, fmt.Sprintf("OITop=%d", cfg.CoinSource.OITopLimit))
}
if cfg.CoinSource.UseOILow {
sourceBits = append(sourceBits, fmt.Sprintf("OILow=%d", cfg.CoinSource.OILowLimit))
}
if len(cfg.CoinSource.StaticCoins) > 0 {
sourceBits = append(sourceBits, "static="+strings.Join(cfg.CoinSource.StaticCoins, ","))
}
timeframes := append([]string(nil), cfg.Indicators.Klines.SelectedTimeframes...)
if len(timeframes) == 0 {
timeframes = cleanStringList([]string{cfg.Indicators.Klines.PrimaryTimeframe, cfg.Indicators.Klines.LongerTimeframe})
}
indicatorBits := make([]string, 0, 8)
if cfg.Indicators.EnableRawKlines {
indicatorBits = append(indicatorBits, "raw_klines")
}
if cfg.Indicators.EnableVolume {
indicatorBits = append(indicatorBits, "volume")
}
if cfg.Indicators.EnableOI {
indicatorBits = append(indicatorBits, "oi")
}
if cfg.Indicators.EnableFundingRate {
indicatorBits = append(indicatorBits, "funding_rate")
}
if cfg.Indicators.EnableEMA {
indicatorBits = append(indicatorBits, "ema")
}
if cfg.Indicators.EnableMACD {
indicatorBits = append(indicatorBits, "macd")
}
if cfg.Indicators.EnableRSI {
indicatorBits = append(indicatorBits, "rsi")
}
if cfg.Indicators.EnableATR {
indicatorBits = append(indicatorBits, "atr")
}
if cfg.Indicators.EnableBOLL {
indicatorBits = append(indicatorBits, "boll")
}
sort.Strings(indicatorBits)
promptBits := make([]string, 0, 5)
if strings.TrimSpace(cfg.PromptSections.RoleDefinition) != "" {
promptBits = append(promptBits, "role_definition")
}
if strings.TrimSpace(cfg.PromptSections.TradingFrequency) != "" {
promptBits = append(promptBits, "trading_frequency")
}
if strings.TrimSpace(cfg.PromptSections.EntryStandards) != "" {
promptBits = append(promptBits, "entry_standards")
}
if strings.TrimSpace(cfg.PromptSections.DecisionProcess) != "" {
promptBits = append(promptBits, "decision_process")
}
customPrompt := strings.TrimSpace(cfg.CustomPrompt)
customPromptPreview := customPrompt
if len([]rune(customPromptPreview)) > 120 {
runes := []rune(customPromptPreview)
customPromptPreview = string(runes[:120]) + "..."
}
if lang == "zh" {
lines := []string{
fmt.Sprintf("策略“%s”概览", name),
fmt.Sprintf("- 类型:%s", defaultIfEmpty(strings.TrimSpace(cfg.StrategyType), "ai_trading")),
fmt.Sprintf("- 语言:%s", defaultIfEmpty(strings.TrimSpace(cfg.Language), "zh")),
}
if strings.TrimSpace(strategy.Description) != "" {
lines = append(lines, fmt.Sprintf("- 描述:%s", strings.TrimSpace(strategy.Description)))
}
if len(sourceBits) > 0 {
lines = append(lines, "- 标的来源:"+strings.Join(sourceBits, " | "))
}
if len(timeframes) > 0 {
lines = append(lines, "- K线周期"+strings.Join(timeframes, " / "))
}
lines = append(lines, fmt.Sprintf("- 仓位风险:最多持仓 %dBTC/ETH 最大杠杆 %d山寨最大杠杆 %d最低置信度 %d",
cfg.RiskControl.MaxPositions, cfg.RiskControl.BTCETHMaxLeverage, cfg.RiskControl.AltcoinMaxLeverage, cfg.RiskControl.MinConfidence))
if len(indicatorBits) > 0 {
lines = append(lines, "- 已启用指标:"+strings.Join(indicatorBits, "、"))
}
if len(promptBits) > 0 {
lines = append(lines, "- Prompt 模块:"+strings.Join(promptBits, "、"))
}
if customPromptPreview != "" {
lines = append(lines, "- 自定义 Prompt"+customPromptPreview)
} else {
lines = append(lines, "- 自定义 Prompt当前为空主要使用策略模板内置 prompt sections。")
}
lines = append(lines, "- 如果你要,我还可以继续展开这条策略的完整参数 JSON或者逐段解释它的 prompt。")
return strings.Join(lines, "\n")
}
lines := []string{
fmt.Sprintf("Strategy %q overview:", name),
fmt.Sprintf("- Type: %s", defaultIfEmpty(strings.TrimSpace(cfg.StrategyType), "ai_trading")),
fmt.Sprintf("- Language: %s", defaultIfEmpty(strings.TrimSpace(cfg.Language), "en")),
}
if strings.TrimSpace(strategy.Description) != "" {
lines = append(lines, fmt.Sprintf("- Description: %s", strings.TrimSpace(strategy.Description)))
}
if len(sourceBits) > 0 {
lines = append(lines, "- Coin source: "+strings.Join(sourceBits, " | "))
}
if len(timeframes) > 0 {
lines = append(lines, "- Timeframes: "+strings.Join(timeframes, " / "))
}
lines = append(lines, fmt.Sprintf("- Risk: max positions %d, BTC/ETH max leverage %d, alt max leverage %d, min confidence %d",
cfg.RiskControl.MaxPositions, cfg.RiskControl.BTCETHMaxLeverage, cfg.RiskControl.AltcoinMaxLeverage, cfg.RiskControl.MinConfidence))
if len(indicatorBits) > 0 {
lines = append(lines, "- Enabled indicators: "+strings.Join(indicatorBits, ", "))
}
if len(promptBits) > 0 {
lines = append(lines, "- Prompt modules: "+strings.Join(promptBits, ", "))
}
if customPromptPreview != "" {
lines = append(lines, "- Custom prompt: "+customPromptPreview)
} else {
lines = append(lines, "- Custom prompt: empty right now; it mainly uses the built-in prompt sections from the strategy template.")
}
lines = append(lines, "- I can also expand the full strategy config JSON or walk through the prompt section by section.")
return strings.Join(lines, "\n")
}
func (a *Agent) describeDefaultStrategyConfig(lang string) string {
if lang != "zh" {
lang = "en"
}
cfg := store.GetDefaultStrategyConfig(lang)
name := "Default Strategy Template"
description := "System default strategy configuration template"
if lang == "zh" {
name = "默认策略模板"
description = "系统默认策略配置模板"
}
return formatStrategyDetailResponse(lang, &store.Strategy{
ID: "default_strategy_template",
Name: name,
Description: description,
}, cfg)
}
func (a *Agent) describeTrader(storeUserID, lang string, target *EntityReference) (string, bool) {
raw := a.toolListTraders(storeUserID)
var payload struct {
Traders []safeTraderToolConfig `json:"traders"`
}
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return "", false
}
trader := findTraderByReference(payload.Traders, target)
if trader == nil {
if len(payload.Traders) != 1 {
return "", false
}
trader = &payload.Traders[0]
}
if lang == "zh" {
status := "未运行"
if trader.IsRunning {
status = "运行中"
}
return fmt.Sprintf("交易员“%s”详情\n- 状态:%s\n- 模型:%s\n- 交易所:%s\n- 策略:%s\n- 扫描间隔:%d 分钟\n- 初始余额:%.2f",
trader.Name, status, trader.AIModelID, trader.ExchangeID, defaultIfEmpty(trader.StrategyID, "未绑定"), trader.ScanIntervalMinutes, trader.InitialBalance), true
}
status := "stopped"
if trader.IsRunning {
status = "running"
}
return fmt.Sprintf("Trader %q details:\n- Status: %s\n- Model: %s\n- Exchange: %s\n- Strategy: %s\n- Scan interval: %d minutes\n- Initial balance: %.2f",
trader.Name, status, trader.AIModelID, trader.ExchangeID, defaultIfEmpty(trader.StrategyID, "none"), trader.ScanIntervalMinutes, trader.InitialBalance), true
}
func (a *Agent) describeExchange(storeUserID, lang string, target *EntityReference) (string, bool) {
raw := a.toolGetExchangeConfigs(storeUserID)
var payload struct {
ExchangeConfigs []safeExchangeToolConfig `json:"exchange_configs"`
}
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return "", false
}
exchange := findExchangeByReference(payload.ExchangeConfigs, target)
if exchange == nil {
if len(payload.ExchangeConfigs) != 1 {
return "", false
}
exchange = &payload.ExchangeConfigs[0]
}
if lang == "zh" {
return fmt.Sprintf("交易所配置“%s”详情\n- 交易所:%s\n- 已启用:%t\n- API Key%t\n- Secret%t\n- Passphrase%t\n- Testnet%t",
defaultIfEmpty(exchange.AccountName, exchange.ID), exchange.ExchangeType, exchange.Enabled, exchange.HasAPIKey, exchange.HasSecretKey, exchange.HasPassphrase, exchange.Testnet), true
}
return fmt.Sprintf("Exchange config %q details:\n- Exchange: %s\n- Enabled: %t\n- API key present: %t\n- Secret present: %t\n- Passphrase present: %t\n- Testnet: %t",
defaultIfEmpty(exchange.AccountName, exchange.ID), exchange.ExchangeType, exchange.Enabled, exchange.HasAPIKey, exchange.HasSecretKey, exchange.HasPassphrase, exchange.Testnet), true
}
func (a *Agent) describeModel(storeUserID, lang string, target *EntityReference) (string, bool) {
raw := a.toolGetModelConfigs(storeUserID)
var payload struct {
ModelConfigs []safeModelToolConfig `json:"model_configs"`
}
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return "", false
}
model := findModelByReference(payload.ModelConfigs, target)
if model == nil {
if len(payload.ModelConfigs) != 1 {
return "", false
}
model = &payload.ModelConfigs[0]
}
if lang == "zh" {
return fmt.Sprintf("模型配置“%s”详情\n- Provider%s\n- 已启用:%t\n- API Key%t\n- URL%s\n- Model Name%s",
defaultIfEmpty(model.Name, model.ID), model.Provider, model.Enabled, model.HasAPIKey, defaultIfEmpty(model.CustomAPIURL, "未设置"), defaultIfEmpty(model.CustomModelName, "未设置")), true
}
return fmt.Sprintf("Model config %q details:\n- Provider: %s\n- Enabled: %t\n- API key present: %t\n- URL: %s\n- Model name: %s",
defaultIfEmpty(model.Name, model.ID), model.Provider, model.Enabled, model.HasAPIKey, defaultIfEmpty(model.CustomAPIURL, "not set"), defaultIfEmpty(model.CustomModelName, "not set")), true
}
func findTraderByReference(items []safeTraderToolConfig, target *EntityReference) *safeTraderToolConfig {
if target == nil {
return nil
}
for i := range items {
if strings.TrimSpace(target.ID) != "" && items[i].ID == strings.TrimSpace(target.ID) {
return &items[i]
}
if strings.TrimSpace(target.Name) != "" && strings.EqualFold(strings.TrimSpace(items[i].Name), strings.TrimSpace(target.Name)) {
return &items[i]
}
}
return nil
}
func findExchangeByReference(items []safeExchangeToolConfig, target *EntityReference) *safeExchangeToolConfig {
if target == nil {
return nil
}
for i := range items {
name := defaultIfEmpty(items[i].AccountName, items[i].Name)
if strings.TrimSpace(target.ID) != "" && items[i].ID == strings.TrimSpace(target.ID) {
return &items[i]
}
if strings.TrimSpace(target.Name) != "" && strings.EqualFold(strings.TrimSpace(name), strings.TrimSpace(target.Name)) {
return &items[i]
}
}
return nil
}
func findModelByReference(items []safeModelToolConfig, target *EntityReference) *safeModelToolConfig {
if target == nil {
return nil
}
for i := range items {
if strings.TrimSpace(target.ID) != "" && items[i].ID == strings.TrimSpace(target.ID) {
return &items[i]
}
if strings.TrimSpace(target.Name) != "" && strings.EqualFold(strings.TrimSpace(items[i].Name), strings.TrimSpace(target.Name)) {
return &items[i]
}
}
return nil
}
func (a *Agent) loadTraderOptions(storeUserID string) []traderSkillOption {
if a.store == nil {
return nil
}
traders, err := a.store.Trader().List(storeUserID)
if err != nil {
return nil
}
out := make([]traderSkillOption, 0, len(traders))
for _, trader := range traders {
out = append(out, traderSkillOption{ID: trader.ID, Name: trader.Name, Enabled: trader.IsRunning})
}
return out
}
func (a *Agent) handleExchangeCreateSkill(storeUserID string, userID int64, lang, text string, session skillSession) string {
if session.Name == "" {
session = skillSession{Name: "exchange_management", Action: "create", Phase: "collecting"}
}
if fieldValue(session, skillDAGStepField) == "" {
setSkillDAGStep(&session, "resolve_exchange_type")
}
if isCancelSkillReply(text) {
a.clearSkillSession(userID)
if lang == "zh" {
return "已取消当前创建交易所配置流程。"
}
return "Cancelled the current exchange creation flow."
}
if v := exchangeTypeFromText(text); fieldValue(session, "exchange_type") == "" && v != "" {
setField(&session, "exchange_type", v)
}
if v := extractTraderName(text); fieldValue(session, "account_name") == "" && v != "" {
setField(&session, "account_name", v)
}
exType := fieldValue(session, "exchange_type")
if actionRequiresSlot("exchange_management", "create", "exchange_type") && exType == "" {
setSkillDAGStep(&session, "resolve_exchange_type")
a.saveSkillSession(userID, session)
if lang == "zh" {
return "要创建交易所配置,我还需要:" + slotDisplayName("exchange_type", lang) + "。例如OKX、Binance、Bybit。"
}
return "To create an exchange config, tell me which exchange to use, for example OKX, Binance, or Bybit."
}
accountName := fieldValue(session, "account_name")
if accountName == "" {
accountName = "Default"
}
setSkillDAGStep(&session, "execute_create")
args := map[string]any{
"action": "create",
"exchange_type": exType,
"account_name": accountName,
}
raw, _ := json.Marshal(args)
resp := a.toolManageExchangeConfig(storeUserID, string(raw))
if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) {
a.saveSkillSession(userID, session)
if lang == "zh" {
return "创建交易所配置失败:" + errMsg
}
return "Failed to create exchange config: " + errMsg
}
a.clearSkillSession(userID)
if lang == "zh" {
return fmt.Sprintf("已创建交易所配置:%s%s。如需继续补 API Key、Secret 或 Passphrase可以直接继续说。", accountName, exType)
}
return fmt.Sprintf("Created exchange config %s (%s). You can continue by adding API key, secret, or passphrase.", accountName, exType)
}
func (a *Agent) handleModelCreateSkill(storeUserID string, userID int64, lang, text string, session skillSession) string {
if session.Name == "" {
session = skillSession{Name: "model_management", Action: "create", Phase: "collecting"}
}
if fieldValue(session, skillDAGStepField) == "" {
setSkillDAGStep(&session, "resolve_provider")
}
if isCancelSkillReply(text) {
a.clearSkillSession(userID)
if lang == "zh" {
return "已取消当前创建模型配置流程。"
}
return "Cancelled the current model creation flow."
}
if v := providerFromText(text); fieldValue(session, "provider") == "" && v != "" {
setField(&session, "provider", v)
}
if v := extractTraderName(text); fieldValue(session, "name") == "" && v != "" {
setField(&session, "name", v)
}
if v := extractURL(text); fieldValue(session, "custom_api_url") == "" && v != "" {
setField(&session, "custom_api_url", v)
}
provider := fieldValue(session, "provider")
if actionRequiresSlot("model_management", "create", "provider") && provider == "" {
setSkillDAGStep(&session, "resolve_provider")
a.saveSkillSession(userID, session)
if lang == "zh" {
return "要创建模型配置,我还需要:" + slotDisplayName("provider", lang) + "例如OpenAI、DeepSeek、Claude、Gemini。"
}
return "To create a model config, I need the provider first, for example OpenAI, DeepSeek, Claude, or Gemini."
}
setSkillDAGStep(&session, "execute_create")
args := map[string]any{
"action": "create",
"provider": provider,
"name": defaultIfEmpty(fieldValue(session, "name"), provider),
"custom_api_url": fieldValue(session, "custom_api_url"),
"custom_model_name": fieldValue(session, "custom_model_name"),
}
raw, _ := json.Marshal(args)
resp := a.toolManageModelConfig(storeUserID, string(raw))
if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) {
a.saveSkillSession(userID, session)
if lang == "zh" {
return "创建模型配置失败:" + errMsg
}
return "Failed to create model config: " + errMsg
}
a.clearSkillSession(userID)
if lang == "zh" {
return fmt.Sprintf("已创建模型配置:%s。你后续还可以继续补 API Key、URL 或模型名。", provider)
}
return fmt.Sprintf("Created model config for %s. You can continue by adding API key, URL, or model name.", provider)
}
func (a *Agent) handleStrategyCreateSkill(storeUserID string, userID int64, lang, text string, session skillSession) string {
if session.Name == "" {
session = skillSession{Name: "strategy_management", Action: "create", Phase: "collecting"}
}
if fieldValue(session, skillDAGStepField) == "" {
setSkillDAGStep(&session, "resolve_name")
}
if isCancelSkillReply(text) {
a.clearSkillSession(userID)
if lang == "zh" {
return "已取消当前创建策略流程。"
}
return "Cancelled the current strategy creation flow."
}
name := fieldValue(session, "name")
if name == "" {
name = extractTraderName(text)
if name == "" {
name = extractPostKeywordName(text, []string{"叫", "名为", "策略叫", "strategy called"})
}
if name != "" {
setField(&session, "name", name)
}
}
if actionRequiresSlot("strategy_management", "create", "name") && name == "" {
setSkillDAGStep(&session, "resolve_name")
a.saveSkillSession(userID, session)
if lang == "zh" {
return "要创建策略,我还需要:" + slotDisplayName("name", lang) + "。你可以直接说创建一个叫“趋势策略A”的策略。"
}
return "To create a strategy, I need a strategy name. You can say: create a strategy called 'Trend A'."
}
setSkillDAGStep(&session, "execute_create")
args := map[string]any{"action": "create", "name": name, "lang": "zh"}
raw, _ := json.Marshal(args)
resp := a.toolManageStrategy(storeUserID, string(raw))
if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) {
a.saveSkillSession(userID, session)
if lang == "zh" {
return "创建策略失败:" + errMsg
}
return "Failed to create strategy: " + errMsg
}
a.clearSkillSession(userID)
if lang == "zh" {
return fmt.Sprintf("已创建策略“%s”。默认配置已就绪你后续可以继续让我帮你改细节。", name)
}
return fmt.Sprintf("Created strategy %q with the default configuration.", name)
}
func (a *Agent) handleSimpleEntitySkill(storeUserID string, userID int64, lang, text string, session skillSession, skillName, action string, options []traderSkillOption) (string, bool) {
if isCancelSkillReply(text) {
a.clearSkillSession(userID)
if lang == "zh" {
return "已取消当前流程。", true
}
return "Cancelled the current flow.", true
}
if session.Name == "" {
session = skillSession{Name: skillName, Action: action, Phase: "collecting"}
}
if session.Name != skillName || session.Action != action {
return "", false
}
if dag, ok := getSkillDAG(skillName, action); ok && len(dag.Steps) > 0 {
currentStep, _ := currentSkillDAGStep(session)
if currentStep.ID == "resolve_target" {
if supportsBulkTargetSelection(skillName, action) && textMeansAllTargets(text) {
setField(&session, "bulk_scope", "all")
advanceSkillDAGStep(&session, currentStep.ID)
} else {
session.TargetRef = resolveTargetFromText(text, options, session.TargetRef)
}
if session.TargetRef == nil {
if !(supportsBulkTargetSelection(skillName, action) && fieldValue(session, "bulk_scope") == "all") {
setSkillDAGStep(&session, "resolve_target")
a.saveSkillSession(userID, session)
label := "可选对象:"
if lang != "zh" {
label = "Available targets:"
}
optionList := formatOptionList(label, options)
if lang == "zh" {
reply := "当前这一步需要先确定目标对象。请告诉我你要操作哪一个。"
if optionList != "" {
reply += "\n" + optionList
}
return reply, true
}
reply := "This step needs a target object first. Tell me which one to operate on."
if optionList != "" {
reply += "\n" + optionList
}
return reply, true
}
}
if fieldValue(session, skillDAGStepField) == currentStep.ID {
advanceSkillDAGStep(&session, currentStep.ID)
}
}
} else {
if supportsBulkTargetSelection(skillName, action) && textMeansAllTargets(text) {
setField(&session, "bulk_scope", "all")
} else {
session.TargetRef = resolveTargetFromText(text, options, session.TargetRef)
}
if session.TargetRef == nil && fieldValue(session, "bulk_scope") != "all" && action != "query" && action != "query_list" && action != "query_detail" && action != "query_running" {
a.saveSkillSession(userID, session)
label := formatOptionList("可选对象:", options)
if lang == "zh" {
reply := "我还需要你明确要操作的是哪一个对象。"
if label != "" {
reply += "\n" + label
}
return reply, true
}
reply := "I still need you to specify which object to operate on."
if label != "" {
reply += "\n" + label
}
return reply, true
}
}
switch skillName {
case "trader_management":
return a.executeTraderManagementAction(storeUserID, userID, lang, text, session), true
case "exchange_management":
return a.executeExchangeManagementAction(storeUserID, userID, lang, text, session), true
case "model_management":
return a.executeModelManagementAction(storeUserID, userID, lang, text, session), true
case "strategy_management":
return a.executeStrategyManagementAction(storeUserID, userID, lang, text, session), true
default:
return "", false
}
}
func defaultIfEmpty(value, fallback string) string {
value = strings.TrimSpace(value)
if value == "" {
return strings.TrimSpace(fallback)
}
return value
}

View File

@@ -1,180 +0,0 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"strings"
"nofx/mcp"
)
const (
skillOutcomeSuccess = "success"
skillOutcomeNeedMoreInfo = "need_more_info"
skillOutcomeRecoverableError = "recoverable_error"
skillOutcomeFatalError = "fatal_error"
skillOutcomeNotHandled = "not_handled"
)
type skillOutcome struct {
Skill string `json:"skill"`
Action string `json:"action"`
Status string `json:"status"`
GoalAchieved bool `json:"goal_achieved"`
UserMessage string `json:"user_message,omitempty"`
ErrorCode string `json:"error_code,omitempty"`
Error string `json:"error,omitempty"`
Data map[string]any `json:"data,omitempty"`
}
type taskReviewDecision struct {
Route string `json:"route"`
Answer string `json:"answer,omitempty"`
}
func normalizeAtomicSkillAction(skill, action string) string {
action = strings.TrimSpace(strings.ToLower(action))
switch skill {
case "trader_management":
switch action {
case "query", "query_list":
return "query_list"
case "query_running":
return "query_running"
case "query_detail":
return "query_detail"
case "update":
return "update_name"
case "update_name", "update_bindings":
return action
}
case "exchange_management":
switch action {
case "query", "query_list":
return "query_list"
case "query_detail":
return "query_detail"
case "update":
return "update_name"
case "update_name", "update_status":
return action
}
case "model_management":
switch action {
case "query", "query_list":
return "query_list"
case "query_detail":
return "query_detail"
case "update":
return "update_name"
case "update_name", "update_endpoint", "update_status":
return action
}
case "strategy_management":
switch action {
case "query", "query_list":
return "query_list"
case "query_detail":
return "query_detail"
case "update":
return "update_name"
case "update_name", "update_config", "update_prompt":
return action
}
}
return action
}
func inferSkillOutcome(skill, action, answer string, activeSession skillSession, data map[string]any) skillOutcome {
outcome := skillOutcome{
Skill: skill,
Action: action,
Status: skillOutcomeSuccess,
UserMessage: strings.TrimSpace(answer),
Data: data,
}
if activeSession.Name != "" {
outcome.Status = skillOutcomeNeedMoreInfo
outcome.GoalAchieved = false
return outcome
}
lower := strings.ToLower(strings.TrimSpace(answer))
switch {
case lower == "":
outcome.Status = skillOutcomeNotHandled
case strings.Contains(lower, "失败") || strings.Contains(lower, "failed") || strings.Contains(lower, "error"):
outcome.Status = skillOutcomeRecoverableError
outcome.Error = strings.TrimSpace(answer)
default:
outcome.GoalAchieved = true
}
return outcome
}
func parseTaskReviewDecision(raw string) (taskReviewDecision, error) {
raw = strings.TrimSpace(raw)
raw = strings.TrimPrefix(raw, "```json")
raw = strings.TrimPrefix(raw, "```")
raw = strings.TrimSuffix(raw, "```")
raw = strings.TrimSpace(raw)
var decision taskReviewDecision
if err := json.Unmarshal([]byte(raw), &decision); err == nil {
decision.Route = strings.TrimSpace(strings.ToLower(decision.Route))
decision.Answer = strings.TrimSpace(decision.Answer)
return decision, nil
}
start := strings.Index(raw, "{")
end := strings.LastIndex(raw, "}")
if start >= 0 && end > start {
if err := json.Unmarshal([]byte(raw[start:end+1]), &decision); err == nil {
decision.Route = strings.TrimSpace(strings.ToLower(decision.Route))
decision.Answer = strings.TrimSpace(decision.Answer)
return decision, nil
}
}
return taskReviewDecision{}, fmt.Errorf("invalid task review json")
}
func (a *Agent) reviewTaskCompletion(ctx context.Context, userID int64, lang, text string, outcome skillOutcome) (taskReviewDecision, error) {
if a.aiClient == nil {
if outcome.Status == skillOutcomeRecoverableError || outcome.Status == skillOutcomeFatalError || outcome.Status == skillOutcomeNotHandled {
return taskReviewDecision{Route: "replan"}, nil
}
return taskReviewDecision{Route: "complete", Answer: outcome.UserMessage}, nil
}
recentConversationCtx := a.buildRecentConversationContext(userID, text)
outcomeJSON, _ := json.Marshal(outcome)
systemPrompt := `You are the task-level Plan-Execute-Review supervisor for NOFXi.
You are reviewing the JSON result returned by one structured skill execution.
Return JSON only. Do not return markdown.
Rules:
- Decide whether the OVERALL user task is finished, not whether the skill itself ran successfully.
- Use route "complete" only when the user's task is now complete or the best next message is a final user-facing reply.
- Use route "replan" when the user's task is not complete yet and the planner should continue from the new skill outcome.
- Prefer route "replan" for recoverable errors, unmet goals, missing prerequisites, or cases where another skill/tool sequence may help.
- If you choose "complete", produce the final user-facing answer in the user's language.
Return JSON with this exact shape:
{"route":"complete|replan","answer":""}`
userPrompt := fmt.Sprintf("Language: %s\nUser message: %s\n\nRecent conversation:\n%s\n\nSkill outcome JSON:\n%s", lang, text, recentConversationCtx, string(outcomeJSON))
stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout)
defer cancel()
raw, err := a.aiClient.CallWithRequest(&mcp.Request{
Messages: []mcp.Message{
mcp.NewSystemMessage(systemPrompt),
mcp.NewUserMessage(userPrompt),
},
Ctx: stageCtx,
})
if err != nil {
return taskReviewDecision{}, err
}
return parseTaskReviewDecision(raw)
}

View File

@@ -1,119 +0,0 @@
package agent
import (
"embed"
"encoding/json"
"fmt"
"sort"
"strings"
)
//go:embed skills/*.json
var embeddedSkillDefinitions embed.FS
type SkillDefinition struct {
Name string `json:"name"`
Kind string `json:"kind"`
Domain string `json:"domain"`
Description string `json:"description"`
Intents []string `json:"intents,omitempty"`
Actions map[string]SkillActionDefinition `json:"actions,omitempty"`
ToolMapping map[string]string `json:"tool_mapping,omitempty"`
}
type SkillActionDefinition struct {
Description string `json:"description,omitempty"`
RequiredSlots []string `json:"required_slots,omitempty"`
OptionalSlots []string `json:"optional_slots,omitempty"`
NeedsConfirmation bool `json:"needs_confirmation,omitempty"`
}
var skillRegistry = mustLoadSkillRegistry()
func mustLoadSkillRegistry() map[string]SkillDefinition {
registry, err := loadSkillRegistry()
if err != nil {
panic(err)
}
return registry
}
func loadSkillRegistry() (map[string]SkillDefinition, error) {
entries, err := embeddedSkillDefinitions.ReadDir("skills")
if err != nil {
return nil, err
}
registry := make(map[string]SkillDefinition, len(entries))
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
continue
}
raw, err := embeddedSkillDefinitions.ReadFile("skills/" + entry.Name())
if err != nil {
return nil, err
}
var def SkillDefinition
if err := json.Unmarshal(raw, &def); err != nil {
return nil, fmt.Errorf("parse skill definition %s: %w", entry.Name(), err)
}
def = normalizeSkillDefinition(def)
if def.Name == "" {
return nil, fmt.Errorf("skill definition %s has empty name", entry.Name())
}
registry[def.Name] = def
}
return registry, nil
}
func normalizeSkillDefinition(def SkillDefinition) SkillDefinition {
def.Name = strings.TrimSpace(def.Name)
def.Kind = strings.TrimSpace(def.Kind)
def.Domain = strings.TrimSpace(def.Domain)
def.Description = strings.TrimSpace(def.Description)
def.Intents = cleanStringList(def.Intents)
if len(def.Actions) > 0 {
normalized := make(map[string]SkillActionDefinition, len(def.Actions))
for key, action := range def.Actions {
key = strings.TrimSpace(key)
if key == "" {
continue
}
action.Description = strings.TrimSpace(action.Description)
action.RequiredSlots = cleanStringList(action.RequiredSlots)
action.OptionalSlots = cleanStringList(action.OptionalSlots)
normalized[key] = action
}
def.Actions = normalized
}
if len(def.ToolMapping) > 0 {
normalized := make(map[string]string, len(def.ToolMapping))
for key, value := range def.ToolMapping {
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
if key == "" || value == "" {
continue
}
normalized[key] = value
}
def.ToolMapping = normalized
}
return def
}
func getSkillDefinition(name string) (SkillDefinition, bool) {
def, ok := skillRegistry[strings.TrimSpace(name)]
return def, ok
}
func listSkillNames() []string {
names := make([]string, 0, len(skillRegistry))
for name := range skillRegistry {
names = append(names, name)
}
sort.Strings(names)
return names
}

View File

@@ -1,55 +0,0 @@
package agent
import "testing"
func TestSkillRegistryLoadsDefinitions(t *testing.T) {
names := listSkillNames()
if len(names) < 4 {
t.Fatalf("expected skill registry to load definitions, got %v", names)
}
for _, name := range []string{
"trader_management",
"exchange_management",
"model_management",
"strategy_management",
"exchange_diagnosis",
"model_diagnosis",
} {
if _, ok := getSkillDefinition(name); !ok {
t.Fatalf("missing skill definition %q", name)
}
}
}
func TestTraderManagementDefinitionHasCreateAction(t *testing.T) {
def, ok := getSkillDefinition("trader_management")
if !ok {
t.Fatalf("missing trader_management definition")
}
action, ok := def.Actions["create"]
if !ok {
t.Fatalf("missing create action in trader_management")
}
if len(action.RequiredSlots) == 0 {
t.Fatalf("expected required slots for trader_management create action")
}
}
func TestActionNeedsConfirmationUsesSkillDefinition(t *testing.T) {
if !actionNeedsConfirmation("exchange_management", "delete") {
t.Fatalf("expected exchange_management delete to require confirmation")
}
if actionNeedsConfirmation("exchange_management", "query") {
t.Fatalf("did not expect exchange_management query to require confirmation")
}
}
func TestActionRequiresSlotUsesSkillDefinition(t *testing.T) {
if !actionRequiresSlot("model_management", "create", "provider") {
t.Fatalf("expected model_management create to require provider")
}
if actionRequiresSlot("model_management", "create", "target_ref") {
t.Fatalf("did not expect model_management create to require target_ref")
}
}

View File

@@ -1,144 +0,0 @@
package agent
import (
"fmt"
"strings"
)
type skillActionRuntime struct {
Skill SkillDefinition
Name string
Action SkillActionDefinition
}
func getSkillActionRuntime(skillName, action string) (skillActionRuntime, bool) {
def, ok := getSkillDefinition(skillName)
if !ok {
return skillActionRuntime{}, false
}
action = strings.TrimSpace(action)
if action == "" {
return skillActionRuntime{Skill: def}, true
}
actionDef, ok := def.Actions[action]
if !ok {
return skillActionRuntime{}, false
}
return skillActionRuntime{
Skill: def,
Name: action,
Action: actionDef,
}, true
}
func actionNeedsConfirmation(skillName, action string) bool {
runtime, ok := getSkillActionRuntime(skillName, action)
if !ok {
return false
}
return runtime.Action.NeedsConfirmation
}
func actionRequiresSlot(skillName, action, slot string) bool {
runtime, ok := getSkillActionRuntime(skillName, action)
if !ok {
return false
}
slot = strings.TrimSpace(slot)
for _, candidate := range runtime.Action.RequiredSlots {
if candidate == slot {
return true
}
}
return false
}
func slotDisplayName(slot, lang string) string {
slot = strings.TrimSpace(slot)
if lang != "zh" {
switch slot {
case "target_ref":
return "target"
case "name":
return "name"
case "exchange":
return "exchange"
case "model":
return "model"
case "strategy":
return "strategy"
case "exchange_type":
return "exchange type"
case "provider":
return "provider"
default:
return slot
}
}
switch slot {
case "target_ref":
return "目标对象"
case "name":
return "名称"
case "exchange":
return "交易所"
case "model":
return "模型"
case "strategy":
return "策略"
case "exchange_type":
return "交易所类型"
case "provider":
return "provider"
default:
return slot
}
}
func formatAwaitConfirmationMessage(lang, action, targetLabel string) string {
actionLabel := action
if lang == "zh" {
switch action {
case "start":
actionLabel = "启动"
case "stop":
actionLabel = "停止"
case "delete":
actionLabel = "删除"
case "activate":
actionLabel = "激活"
default:
actionLabel = action
}
return fmt.Sprintf("即将%s“%s”。这是需要确认的操作请回复“确认”继续回复“取消”终止。", actionLabel, targetLabel)
}
return fmt.Sprintf("You are about to %s %q. Please reply 'confirm' to continue or 'cancel' to stop.", actionLabel, targetLabel)
}
func formatStillWaitingConfirmationMessage(lang string) string {
if lang == "zh" {
return "当前流程仍在等待你确认。回复“确认”继续,或“取消”终止。"
}
return "This flow is still waiting for your confirmation."
}
func beginConfirmationIfNeeded(userID int64, lang string, session *skillSession, targetLabel string) (string, bool) {
if session == nil || !actionNeedsConfirmation(session.Name, session.Action) {
return "", false
}
if session.Phase != "await_confirmation" {
session.Phase = "await_confirmation"
return formatAwaitConfirmationMessage(lang, session.Action, targetLabel), true
}
return "", false
}
func awaitingConfirmationButNotApproved(lang string, session skillSession, text string) (string, bool) {
if !actionNeedsConfirmation(session.Name, session.Action) || session.Phase != "await_confirmation" {
return "", false
}
if isYesReply(text) {
return "", false
}
return formatStillWaitingConfirmationMessage(lang), true
}

View File

@@ -1,6 +0,0 @@
{
"name": "exchange_diagnosis",
"kind": "diagnosis",
"domain": "exchange",
"description": "当用户反馈交易所 API 连接失败、签名错误、timestamp 异常、权限不足、IP 白名单限制、账户不可用等问题时调用。适用于用户在手动配置或运行交易员时遇到的交易所接入故障。不用于创建、修改、删除或查询交易所配置这类管理操作。"
}

View File

@@ -1,32 +0,0 @@
{
"name": "exchange_management",
"kind": "management",
"domain": "exchange",
"description": "当用户想创建、查看、修改或删除交易所账户配置时调用。适用于用户提到交易所账户、API Key、Secret、Passphrase、测试网开关、启用状态等配置管理需求。不用于排查 invalid signature、timestamp、权限不足、白名单限制等连接或鉴权诊断问题。",
"actions": {
"create": {
"description": "创建新的交易所配置。",
"required_slots": ["exchange_type"],
"optional_slots": ["account_name", "api_key", "secret_key", "passphrase", "testnet"]
},
"update": {
"description": "更新已有交易所配置。",
"required_slots": ["target_ref"],
"optional_slots": ["account_name", "api_key", "secret_key", "passphrase", "enabled", "testnet"]
},
"delete": {
"description": "删除交易所配置。",
"required_slots": ["target_ref"],
"needs_confirmation": true
},
"query": {
"description": "查询交易所配置。"
}
},
"tool_mapping": {
"create": "manage_exchange_config:create",
"update": "manage_exchange_config:update",
"delete": "manage_exchange_config:delete",
"query": "get_exchange_configs"
}
}

View File

@@ -1,6 +0,0 @@
{
"name": "model_diagnosis",
"kind": "diagnosis",
"domain": "model",
"description": "当用户反馈模型配置失败、API Key 无效、Base URL 非法、模型名不匹配、调用返回错误、模型不可用等问题时调用。适用于用户在接入或测试大模型时遇到的配置与兼容性故障。不用于创建、修改、删除或查询模型配置这类管理操作。"
}

View File

@@ -1,32 +0,0 @@
{
"name": "model_management",
"kind": "management",
"domain": "model",
"description": "当用户想创建、查看、修改或删除 AI 模型配置时调用。适用于用户提到 provider、API Key、Base URL、模型名称、启用状态等配置管理需求。不用于排查模型调用失败、接口不兼容、鉴权错误、模型不存在等诊断问题。",
"actions": {
"create": {
"description": "创建新的模型配置。",
"required_slots": ["provider"],
"optional_slots": ["name", "api_key", "custom_api_url", "custom_model_name", "enabled"]
},
"update": {
"description": "更新已有模型配置。",
"required_slots": ["target_ref"],
"optional_slots": ["api_key", "custom_api_url", "custom_model_name", "enabled"]
},
"delete": {
"description": "删除模型配置。",
"required_slots": ["target_ref"],
"needs_confirmation": true
},
"query": {
"description": "查询模型配置。"
}
},
"tool_mapping": {
"create": "manage_model_config:create",
"update": "manage_model_config:update",
"delete": "manage_model_config:delete",
"query": "get_model_configs"
}
}

View File

@@ -1,6 +0,0 @@
{
"name": "strategy_diagnosis",
"kind": "diagnosis",
"domain": "strategy",
"description": "当用户反馈策略未生效、策略输出异常、提示词或配置结果与预期不一致、策略执行表现异常时调用。适用于策略内容和执行效果相关的排障与解释。不用于创建、修改、删除、激活、复制或查询策略模板这类管理操作。"
}

View File

@@ -1,42 +0,0 @@
{
"name": "strategy_management",
"kind": "management",
"domain": "strategy",
"description": "当用户想创建、查看、修改、删除、激活或复制策略模板时调用。适用于用户提到策略名称、策略配置、描述、语言、激活状态、复制新版本等管理需求。不用于排查策略未生效、策略输出异常、执行结果异常等诊断问题。",
"actions": {
"create": {
"description": "创建策略模板。",
"required_slots": ["name"],
"optional_slots": ["config", "description", "lang"]
},
"update": {
"description": "更新策略模板。",
"required_slots": ["target_ref"],
"optional_slots": ["name", "config", "description"]
},
"delete": {
"description": "删除策略模板。",
"required_slots": ["target_ref"],
"needs_confirmation": true
},
"activate": {
"description": "激活策略模板。",
"required_slots": ["target_ref"]
},
"duplicate": {
"description": "复制策略模板。",
"required_slots": ["target_ref", "name"]
},
"query": {
"description": "查询策略模板。"
}
},
"tool_mapping": {
"create": "manage_strategy:create",
"update": "manage_strategy:update",
"delete": "manage_strategy:delete",
"activate": "manage_strategy:activate",
"duplicate": "manage_strategy:duplicate",
"query": "get_strategies"
}
}

View File

@@ -1,6 +0,0 @@
{
"name": "trader_diagnosis",
"kind": "diagnosis",
"domain": "trader",
"description": "当用户反馈交易员无法启动、启动后不交易、绑定模型或交易所缺失、运行状态异常、收益或仓位表现异常时调用。适用于交易员运行过程中的排障与原因定位。不用于创建、修改、删除、启动、停止或查询交易员这类管理操作。"
}

View File

@@ -1,52 +0,0 @@
{
"name": "trader_management",
"kind": "management",
"domain": "trader",
"description": "当用户想创建、查看、修改、删除、启动或停止交易员时调用。适用于用户提到交易员名称、绑定交易所、绑定模型、绑定策略、扫描频率、自定义提示词、运行状态等管理需求。不用于排查交易员启动失败、未下单、收益异常、仓位异常等诊断问题。",
"intents": [
"创建交易员",
"修改交易员",
"删除交易员",
"启动交易员",
"停止交易员",
"查询交易员"
],
"actions": {
"create": {
"description": "创建新的交易员。",
"required_slots": ["name", "exchange", "model"],
"optional_slots": ["strategy", "auto_start"]
},
"update": {
"description": "更新已有交易员。",
"required_slots": ["target_ref"],
"optional_slots": ["name", "exchange", "model", "strategy", "scan_interval_minutes", "custom_prompt"]
},
"delete": {
"description": "删除交易员。",
"required_slots": ["target_ref"],
"needs_confirmation": true
},
"start": {
"description": "启动交易员。",
"required_slots": ["target_ref"],
"needs_confirmation": true
},
"stop": {
"description": "停止交易员。",
"required_slots": ["target_ref"],
"needs_confirmation": true
},
"query": {
"description": "查询交易员列表或状态。"
}
},
"tool_mapping": {
"create": "manage_trader:create",
"update": "manage_trader:update",
"delete": "manage_trader:delete",
"start": "manage_trader:start",
"stop": "manage_trader:stop",
"query": "manage_trader:list"
}
}

View File

@@ -1,444 +0,0 @@
package agent
import (
"nofx/safe"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"golang.org/x/text/encoding/simplifiedchinese"
"golang.org/x/text/transform"
)
// stockHTTPClient is a shared HTTP client for stock API requests.
// Reused across calls for connection pooling.
var stockHTTPClient = &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 10,
MaxIdleConnsPerHost: 5,
IdleConnTimeout: 90 * time.Second,
},
}
// StockQuote holds real-time stock data.
type StockQuote struct {
Name string
Code string
Market string // "A股", "港股", "美股"
Currency string // "CNY", "HKD", "USD"
Open float64
PrevClose float64
Price float64
High float64
Low float64
Volume float64
Turnover float64
Date string
Time string
Change float64
ChangePct float64
// 盘前盘后 (美股)
ExtPrice float64 // 盘前/盘后价格
ExtChangePct float64 // 盘前/盘后涨跌幅%
ExtChange float64 // 盘前/盘后涨跌额
ExtTime string // 盘前/盘后时间
IsExtHours bool // 是否在盘前盘后时段
}
// knownStocks maps Chinese names to stock codes.
var knownStocks = map[string]string{
// A股
"拓维信息": "sz002261", "比亚迪": "sz002594", "宁德时代": "sz300750",
"贵州茅台": "sh600519", "中国平安": "sh601318", "招商银行": "sh600036",
"中芯国际": "sh688981", "工商银行": "sh601398", "建设银行": "sh601939",
"中国银行": "sh601988", "农业银行": "sh601288", "中信证券": "sh600030",
"海康威视": "sz002415", "立讯精密": "sz002475", "东方财富": "sz300059",
"隆基绿能": "sh601012", "长城汽车": "sh601633", "科大讯飞": "sz002230",
"三六零": "sh601360", "中兴通讯": "sz000063",
// 港股
"腾讯": "hk00700", "阿里巴巴": "hk09988", "美团": "hk03690",
"小米": "hk01810", "京东": "hk09618", "网易": "hk09999",
"百度": "hk09888", "快手": "hk01024", "哔哩哔哩": "hk09626",
"理想汽车": "hk02015", "蔚来": "hk09866", "小鹏汽车": "hk09868",
// 华为 is not publicly listed — removed incorrect Tencent fallback
// 美股
"苹果": "gb_aapl", "特斯拉": "gb_tsla", "英伟达": "gb_nvda",
"微软": "gb_msft", "谷歌": "gb_googl", "亚马逊": "gb_amzn",
"meta": "gb_meta", "奈飞": "gb_nflx", "台积电": "gb_tsm",
"拼多多": "gb_pdd", "蔚来汽车": "gb_nio",
}
// US stock ticker mapping
var usTickerMap = map[string]string{
"AAPL": "gb_aapl", "TSLA": "gb_tsla", "NVDA": "gb_nvda", "MSFT": "gb_msft",
"GOOGL": "gb_googl", "AMZN": "gb_amzn", "META": "gb_meta", "NFLX": "gb_nflx",
"TSM": "gb_tsm", "PDD": "gb_pdd", "NIO": "gb_nio", "BABA": "gb_baba",
"JD": "gb_jd", "BIDU": "gb_bidu", "AMD": "gb_amd", "INTC": "gb_intc",
"COIN": "gb_coin", "MARA": "gb_mara", "RIOT": "gb_riot",
}
func resolveStockCode(text string) (string, string) {
// Known Chinese names
for name, code := range knownStocks {
if strings.Contains(text, name) {
return code, name
}
}
// US ticker symbols (uppercase)
upper := strings.ToUpper(text)
for ticker, code := range usTickerMap {
if strings.Contains(upper, ticker) {
return code, ticker
}
}
// 6-digit A-share code
for _, w := range strings.Fields(text) {
w = strings.TrimSpace(w)
if len(w) == 6 {
if _, err := strconv.Atoi(w); err == nil {
prefix := "sz"
if w[0] == '6' || w[0] == '9' { prefix = "sh" }
return prefix + w, w
}
}
// 5-digit HK code
if len(w) == 5 {
if _, err := strconv.Atoi(w); err == nil {
return "hk" + w, w
}
}
}
return "", ""
}
// SearchResult represents a stock search result from Sina suggest API.
type SearchResult struct {
Name string // Display name
Code string // Sina-style code (e.g. sz300750, hk00700, gb_tsla)
Ticker string // Raw ticker (e.g. 300750, 00700, tsla)
Type string // Market type code: 11=A股, 31=港股, 41=美股
Market string // "A股", "港股", "美股"
}
// searchStock queries Sina's suggest API for dynamic stock search.
// Returns matching stocks across A-share, HK, and US markets.
func searchStock(keyword string) ([]SearchResult, error) {
// type=11 (A股), 31 (港股), 41 (美股)
u := fmt.Sprintf("https://suggest3.sinajs.cn/suggest/type=11,31,41&key=%s&name=suggestdata",
url.QueryEscape(keyword))
req, _ := http.NewRequest("GET", u, nil)
req.Header.Set("Referer", "https://finance.sina.com.cn")
resp, err := stockHTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("stock search API returned status %d", resp.StatusCode)
}
reader := transform.NewReader(io.LimitReader(resp.Body, 256*1024), simplifiedchinese.GBK.NewDecoder())
body, err := safe.ReadAllLimited(reader)
if err != nil {
return nil, err
}
line := string(body)
// Parse: var suggestdata="item1;item2;..."
start := strings.Index(line, "\"")
end := strings.LastIndex(line, "\"")
if start == -1 || end <= start {
return nil, fmt.Errorf("invalid suggest response")
}
data := line[start+1 : end]
if data == "" {
return nil, nil // no results
}
var results []SearchResult
items := strings.Split(data, ";")
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" {
continue
}
fields := strings.Split(item, ",")
if len(fields) < 5 {
continue
}
// fields: [0]=name, [1]=type, [2]=ticker, [3]=sinaCode, [4]=displayName
typeCode := fields[1]
ticker := fields[2]
sinaCode := fields[3]
displayName := fields[4]
if displayName == "" {
displayName = fields[0]
}
var mkt, code string
switch typeCode {
case "11": // A股
mkt = "A股"
code = sinaCode // already like sz300750, sh600519
if code == "" {
// Build from ticker
prefix := "sz"
if len(ticker) == 6 && (ticker[0] == '6' || ticker[0] == '9') {
prefix = "sh"
}
code = prefix + ticker
}
case "31": // 港股
mkt = "港股"
code = "hk" + ticker
case "41": // 美股
mkt = "美股"
code = "gb_" + ticker
default:
continue // skip funds (201), indices, etc.
}
results = append(results, SearchResult{
Name: displayName,
Code: code,
Ticker: ticker,
Type: typeCode,
Market: mkt,
})
}
return results, nil
}
// resolveStockCodeDynamic tries local map first, then falls back to Sina search API.
func resolveStockCodeDynamic(text string) (string, string) {
// First try the static map
code, name := resolveStockCode(text)
if code != "" {
return code, name
}
// Fall back to Sina search API
// Extract a meaningful search keyword from the text
keyword := extractStockKeyword(text)
if keyword == "" {
return "", ""
}
results, err := searchStock(keyword)
if err != nil || len(results) == 0 {
return "", ""
}
// Return the first (best) result
return results[0].Code, results[0].Name
}
// extractStockKeyword extracts a likely stock name/ticker from user text.
func extractStockKeyword(text string) string {
// Remove common prefixes/suffixes that aren't stock names
text = strings.TrimSpace(text)
// If the text itself is short enough, use it directly
// (e.g. "中远海控" or "AAPL")
if len([]rune(text)) <= 10 {
return text
}
// Try to extract quoted terms first: 「xxx」 or "xxx"
quotePairs := [][2]string{
{"「", "」"},
{"\u201c", "\u201d"},
{"\u2018", "\u2019"},
{"\"", "\""},
}
for _, pair := range quotePairs {
if s := strings.Index(text, pair[0]); s >= 0 {
if e := strings.Index(text[s+len(pair[0]):], pair[1]); e >= 0 {
return text[s+len(pair[0]) : s+len(pair[0])+e]
}
}
}
// Look for patterns like "查 XXX", "搜索 XXX", "查一下 XXX"
for _, prefix := range []string{"查一下", "搜索", "查询", "看看", "搜一下", "查", "看", "search ", "find "} {
if idx := strings.Index(text, prefix); idx >= 0 {
rest := strings.TrimSpace(text[idx+len(prefix):])
// Take the first "word" (either Chinese characters or English word)
words := strings.Fields(rest)
if len(words) > 0 {
return words[0]
}
}
}
// Last resort: use first few words
words := strings.Fields(text)
if len(words) > 0 {
return words[0]
}
return ""
}
func fetchStockQuote(code string) (*StockQuote, error) {
url := fmt.Sprintf("https://hq.sinajs.cn/list=%s", code)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Referer", "https://finance.sina.com.cn")
resp, err := stockHTTPClient.Do(req)
if err != nil { return nil, err }
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("stock quote API returned status %d", resp.StatusCode)
}
reader := transform.NewReader(io.LimitReader(resp.Body, 256*1024), simplifiedchinese.GBK.NewDecoder())
body, err := safe.ReadAllLimited(reader)
if err != nil { return nil, err }
line := string(body)
start := strings.Index(line, "\"")
end := strings.LastIndex(line, "\"")
if start == -1 || end <= start { return nil, fmt.Errorf("invalid response") }
data := line[start+1 : end]
if data == "" { return nil, fmt.Errorf("empty data for %s", code) }
if strings.HasPrefix(code, "sh") || strings.HasPrefix(code, "sz") {
return parseAShare(code, data)
} else if strings.HasPrefix(code, "hk") {
return parseHKShare(code, data)
} else if strings.HasPrefix(code, "gb_") {
return parseUSShare(code, data)
}
return nil, fmt.Errorf("unsupported market: %s", code)
}
func parseAShare(code, data string) (*StockQuote, error) {
f := strings.Split(data, ",")
if len(f) < 32 { return nil, fmt.Errorf("too few fields") }
q := &StockQuote{Name: f[0], Code: code, Market: "A股", Currency: "CNY"}
q.Open, _ = strconv.ParseFloat(f[1], 64)
q.PrevClose, _ = strconv.ParseFloat(f[2], 64)
q.Price, _ = strconv.ParseFloat(f[3], 64)
q.High, _ = strconv.ParseFloat(f[4], 64)
q.Low, _ = strconv.ParseFloat(f[5], 64)
q.Volume, _ = strconv.ParseFloat(f[8], 64)
q.Turnover, _ = strconv.ParseFloat(f[9], 64)
q.Date = f[30]; q.Time = f[31]
if q.PrevClose > 0 { q.Change = q.Price - q.PrevClose; q.ChangePct = (q.Change / q.PrevClose) * 100 }
return q, nil
}
func parseHKShare(code, data string) (*StockQuote, error) {
f := strings.Split(data, ",")
if len(f) < 18 { return nil, fmt.Errorf("too few fields") }
q := &StockQuote{Name: f[1], Code: code, Market: "港股", Currency: "HKD"}
q.PrevClose, _ = strconv.ParseFloat(f[3], 64)
q.Open, _ = strconv.ParseFloat(f[2], 64)
q.High, _ = strconv.ParseFloat(f[4], 64)
q.Low, _ = strconv.ParseFloat(f[5], 64)
q.Price, _ = strconv.ParseFloat(f[6], 64)
q.Change, _ = strconv.ParseFloat(f[7], 64)
q.ChangePct, _ = strconv.ParseFloat(f[8], 64)
q.Turnover, _ = strconv.ParseFloat(f[10], 64)
q.Volume, _ = strconv.ParseFloat(f[11], 64)
if len(f) > 17 { q.Date = f[17]; q.Time = f[17] }
return q, nil
}
func parseUSShare(code, data string) (*StockQuote, error) {
f := strings.Split(data, ",")
if len(f) < 30 { return nil, fmt.Errorf("too few fields") }
q := &StockQuote{Name: f[0], Code: code, Market: "美股", Currency: "USD"}
q.Price, _ = strconv.ParseFloat(f[1], 64)
q.ChangePct, _ = strconv.ParseFloat(f[2], 64)
q.Change, _ = strconv.ParseFloat(f[4], 64)
q.Open, _ = strconv.ParseFloat(f[5], 64)
q.High, _ = strconv.ParseFloat(f[6], 64)
q.Low, _ = strconv.ParseFloat(f[7], 64)
// 52wk high/low
high52, _ := strconv.ParseFloat(f[8], 64)
low52, _ := strconv.ParseFloat(f[9], 64)
q.Volume, _ = strconv.ParseFloat(f[10], 64)
q.Turnover, _ = strconv.ParseFloat(f[11], 64)
if len(f) > 25 { q.Date = f[25]; q.Time = f[26] }
q.PrevClose = q.Price - q.Change
_ = high52; _ = low52
// 盘前盘后数据 (字段21=价格, 22=涨跌幅%, 23=涨跌额, 24=时间)
if len(f) > 24 {
extPrice, _ := strconv.ParseFloat(f[21], 64)
extPct, _ := strconv.ParseFloat(f[22], 64)
extChg, _ := strconv.ParseFloat(f[23], 64)
if extPrice > 0 {
q.ExtPrice = extPrice
q.ExtChangePct = extPct
q.ExtChange = extChg
q.ExtTime = strings.TrimSpace(f[24])
q.IsExtHours = true
}
}
return q, nil
}
func formatStockQuote(q *StockQuote) string {
emoji := "🟢"
if q.ChangePct < 0 { emoji = "🔴" }
sym := "¥"
if q.Currency == "USD" { sym = "$" }
if q.Currency == "HKD" { sym = "HK$" }
volStr := fmt.Sprintf("%.0f", q.Volume)
if q.Volume > 1000000 { volStr = fmt.Sprintf("%.1f万", q.Volume/10000) }
if q.Volume > 100000000 { volStr = fmt.Sprintf("%.2f亿", q.Volume/100000000) }
turnStr := fmt.Sprintf("%.0f", q.Turnover)
if q.Turnover > 100000000 { turnStr = fmt.Sprintf("%.2f亿", q.Turnover/100000000) }
result := fmt.Sprintf(`%s *%s* (%s · %s)
💰 现价: %s%.2f (%+.2f%%)
📊 开盘: %s%.2f | 昨收: %s%.2f
📈 最高: %s%.2f | 最低: %s%.2f
📦 成交: %s | 额: %s
🕐 %s`,
emoji, q.Name, q.Code, q.Market,
sym, q.Price, q.ChangePct,
sym, q.Open, sym, q.PrevClose,
sym, q.High, sym, q.Low,
volStr, turnStr,
q.Date)
// 盘前盘后数据
if q.IsExtHours && q.ExtPrice > 0 {
extEmoji := "🟢"
if q.ExtChangePct < 0 { extEmoji = "🔴" }
extLabel := "🌙 盘后"
if strings.Contains(strings.ToLower(q.ExtTime), "am") {
extLabel = "🌅 盘前"
}
result += fmt.Sprintf("\n%s %s: %s%.2f (%+.2f%%) %s",
extLabel, extEmoji, sym, q.ExtPrice, q.ExtChangePct, q.ExtTime)
}
return result
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,65 +0,0 @@
package agent
import "testing"
func TestIsStockSymbol(t *testing.T) {
tests := []struct {
sym string
want bool
}{
// Known crypto base symbols — must NOT be detected as stock
{"BTC", false},
{"ETH", false},
{"SOL", false},
{"BNB", false},
{"XRP", false},
{"DOGE", false},
{"ADA", false},
{"AVAX", false},
{"DOT", false},
{"LINK", false},
{"PEPE", false},
{"SHIB", false},
{"TRUMP", false},
{"USDT", false},
{"USDC", false},
{"W", false}, // single letter crypto
// Crypto pairs — must NOT be stock
{"BTCUSDT", false},
{"ETHUSDT", false},
{"SOLUSDT", false},
{"DOGEUSDT", false},
// Real stock tickers — must be detected as stock
{"AAPL", true},
{"TSLA", true},
{"NVDA", true},
{"MSFT", true},
{"GOOGL", true},
{"AMZN", true},
{"META", true},
{"AMD", true},
{"PLTR", true},
{"BA", true},
{"F", true}, // Ford — 1 letter
{"GM", true}, // 2 letters
{"JPM", true}, // 3 letters
// Mixed / edge cases
{"btc", false}, // lowercase crypto
{"aapl", true}, // lowercase stock (uppercased internally)
{"BTC123", false}, // not pure letters
{"123456", false}, // digits
{"", false},
}
for _, tt := range tests {
t.Run(tt.sym, func(t *testing.T) {
got := isStockSymbol(tt.sym)
if got != tt.want {
t.Errorf("isStockSymbol(%q) = %v, want %v", tt.sym, got, tt.want)
}
})
}
}

View File

@@ -1,356 +0,0 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"sync"
"time"
)
// TradeAction represents a parsed trade intent from the LLM or user.
type TradeAction struct {
ID string `json:"id"`
Action string `json:"action"` // "open_long", "open_short", "close_long", "close_short"
Symbol string `json:"symbol"` // e.g. "BTCUSDT"
Quantity float64 `json:"quantity"` // amount
Leverage int `json:"leverage"` // leverage multiplier
TraderID string `json:"trader_id"` // which trader to use
Status string `json:"status"` // "pending", "confirmed", "executed", "failed", "expired"
CreatedAt int64 `json:"created_at"`
Error string `json:"error,omitempty"`
}
// pendingTrades stores pending trade confirmations.
type pendingTrades struct {
mu sync.RWMutex
trades map[string]*TradeAction // id -> trade
}
func newPendingTrades() *pendingTrades {
return &pendingTrades{trades: make(map[string]*TradeAction)}
}
func (p *pendingTrades) Add(t *TradeAction) {
p.mu.Lock()
defer p.mu.Unlock()
p.trades[t.ID] = t
}
func (p *pendingTrades) Get(id string) *TradeAction {
p.mu.RLock()
defer p.mu.RUnlock()
return p.trades[id]
}
func (p *pendingTrades) Remove(id string) {
p.mu.Lock()
defer p.mu.Unlock()
delete(p.trades, id)
}
// CleanExpired removes trades older than 5 minutes.
func (p *pendingTrades) CleanExpired() {
p.mu.Lock()
defer p.mu.Unlock()
cutoff := time.Now().Add(-5 * time.Minute).Unix()
for id, t := range p.trades {
if t.CreatedAt < cutoff {
delete(p.trades, id)
}
}
}
// parseTradeCommand parses natural language trade commands.
// Returns nil if the message is not a trade command.
func parseTradeCommand(text string) *TradeAction {
upper := strings.ToUpper(strings.TrimSpace(text))
// Pattern: "做多 BTC 0.01" / "做空 ETH 0.1" / "long BTC 0.01" / "short ETH 0.1"
// Also: "平多 BTC" / "平空 ETH" / "close long BTC" / "close short ETH"
var action, symbol string
var quantity float64
var leverage int
words := strings.Fields(upper)
if len(words) < 2 {
return nil
}
switch words[0] {
case "做多", "LONG", "BUY":
action = "open_long"
case "做空", "SHORT", "SELL":
action = "open_short"
case "平多":
action = "close_long"
case "平空":
action = "close_short"
case "CLOSE":
if len(words) >= 3 {
switch words[1] {
case "LONG":
action = "close_long"
words = append(words[:1], words[2:]...) // remove "LONG"
case "SHORT":
action = "close_short"
words = append(words[:1], words[2:]...) // remove "SHORT"
}
}
if action == "" {
return nil
}
default:
return nil
}
// Parse symbol
if len(words) < 2 {
return nil
}
symbol = words[1]
// Only append USDT for crypto symbols, not stock tickers
if !isStockSymbol(symbol) && !strings.HasSuffix(symbol, "USDT") {
symbol += "USDT"
}
// Parse quantity (optional)
if len(words) >= 3 {
fmt.Sscanf(words[2], "%f", &quantity)
}
// Parse leverage (optional, "x10" or "10x")
if len(words) >= 4 {
lev := strings.TrimSuffix(strings.TrimPrefix(words[3], "X"), "X")
fmt.Sscanf(lev, "%d", &leverage)
}
if action == "" || symbol == "" {
return nil
}
return &TradeAction{
ID: fmt.Sprintf("trade_%d", time.Now().UnixNano()),
Action: action,
Symbol: symbol,
Quantity: quantity,
Leverage: leverage,
Status: "pending",
CreatedAt: time.Now().Unix(),
}
}
// executeTrade performs the actual trade execution via TraderManager.
func (a *Agent) executeTrade(ctx context.Context, trade *TradeAction) error {
if a.traderManager == nil {
return fmt.Errorf("no trader manager available")
}
traders := a.traderManager.GetAllTraders()
if len(traders) == 0 {
return fmt.Errorf("no traders configured")
}
// Determine if this is a stock trade to route to the right exchange
wantStock := isStockSymbol(trade.Symbol)
// Find a running trader's underlying exchange interface
var underlyingTrader interface {
OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error)
OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error)
CloseLong(symbol string, quantity float64) (map[string]interface{}, error)
CloseShort(symbol string, quantity float64) (map[string]interface{}, error)
}
for _, t := range traders {
s := t.GetStatus()
running, _ := s["is_running"].(bool)
if running {
ut := t.GetUnderlyingTrader()
if ut == nil {
continue
}
// Route stock symbols to alpaca traders, crypto to others
exchange := t.GetExchange()
isAlpaca := exchange == "alpaca"
if wantStock && !isAlpaca {
continue // Skip non-stock traders for stock symbols
}
if !wantStock && isAlpaca {
continue // Skip stock traders for crypto symbols
}
underlyingTrader = ut
break
}
}
if underlyingTrader == nil {
if wantStock {
return fmt.Errorf("no running stock trader (Alpaca) found — configure one to trade stocks")
}
return fmt.Errorf("no running trader supports trade execution")
}
// Sanity caps to prevent LLM hallucinations or input errors from causing damage.
const maxQuantity = 100000.0
const maxLeverage = 125
if trade.Leverage > maxLeverage {
return fmt.Errorf("leverage %dx exceeds maximum allowed (%dx)", trade.Leverage, maxLeverage)
}
switch trade.Action {
case "open_long":
if trade.Quantity <= 0 {
return fmt.Errorf("quantity must be > 0")
}
if trade.Quantity > maxQuantity {
return fmt.Errorf("quantity %.4f exceeds maximum allowed (%.0f)", trade.Quantity, maxQuantity)
}
_, err := underlyingTrader.OpenLong(trade.Symbol, trade.Quantity, trade.Leverage)
return err
case "open_short":
if trade.Quantity <= 0 {
return fmt.Errorf("quantity must be > 0")
}
if trade.Quantity > maxQuantity {
return fmt.Errorf("quantity %.4f exceeds maximum allowed (%.0f)", trade.Quantity, maxQuantity)
}
_, err := underlyingTrader.OpenShort(trade.Symbol, trade.Quantity, trade.Leverage)
return err
case "close_long":
_, err := underlyingTrader.CloseLong(trade.Symbol, trade.Quantity)
return err
case "close_short":
_, err := underlyingTrader.CloseShort(trade.Symbol, trade.Quantity)
return err
default:
return fmt.Errorf("unknown action: %s", trade.Action)
}
}
// formatTradeConfirmation creates a confirmation message for a pending trade.
func formatTradeConfirmation(trade *TradeAction, lang string) string {
actionNames := map[string]string{
"open_long": "做多 (Long)",
"open_short": "做空 (Short)",
"close_long": "平多 (Close Long)",
"close_short": "平空 (Close Short)",
}
symbol := trade.Symbol
if strings.HasSuffix(symbol, "USDT") {
symbol = strings.TrimSuffix(symbol, "USDT")
}
actionName := actionNames[trade.Action]
if actionName == "" {
actionName = trade.Action
}
if lang == "zh" {
msg := fmt.Sprintf("⚠️ **交易确认**\n\n"+
"操作: %s\n"+
"品种: %s\n", actionName, symbol)
if trade.Quantity > 0 {
msg += fmt.Sprintf("数量: %.4f\n", trade.Quantity)
}
if trade.Leverage > 0 {
msg += fmt.Sprintf("杠杆: %dx\n", trade.Leverage)
}
msg += fmt.Sprintf("\n发送 `确认 %s` 执行交易,或忽略取消。", trade.ID)
return msg
}
msg := fmt.Sprintf("⚠️ **Trade Confirmation**\n\n"+
"Action: %s\n"+
"Symbol: %s\n", actionName, symbol)
if trade.Quantity > 0 {
msg += fmt.Sprintf("Quantity: %.4f\n", trade.Quantity)
}
if trade.Leverage > 0 {
msg += fmt.Sprintf("Leverage: %dx\n", trade.Leverage)
}
msg += fmt.Sprintf("\nSend `confirm %s` to execute, or ignore to cancel.", trade.ID)
return msg
}
// handleTradeConfirmation processes a trade confirmation message.
func (a *Agent) handleTradeConfirmation(ctx context.Context, userID int64, text, lang string) (string, bool) {
upper := strings.ToUpper(strings.TrimSpace(text))
var tradeID string
if strings.HasPrefix(upper, "确认 ") || strings.HasPrefix(upper, "CONFIRM ") {
parts := strings.Fields(text)
if len(parts) >= 2 {
tradeID = parts[1]
}
}
if tradeID == "" {
return "", false
}
if a.pending == nil {
return "", false
}
trade := a.pending.Get(tradeID)
if trade == nil {
if lang == "zh" {
return "❌ 交易已过期或不存在。", true
}
return "❌ Trade expired or not found.", true
}
a.pending.Remove(tradeID)
trade.Status = "confirmed"
a.logger.Info("executing trade",
slog.String("id", trade.ID),
slog.String("action", trade.Action),
slog.String("symbol", trade.Symbol),
slog.Float64("quantity", trade.Quantity),
)
err := a.executeTrade(ctx, trade)
if err != nil {
trade.Status = "failed"
trade.Error = err.Error()
if lang == "zh" {
return fmt.Sprintf("❌ 交易执行失败: %s", err.Error()), true
}
return fmt.Sprintf("❌ Trade execution failed: %s", err.Error()), true
}
trade.Status = "executed"
symbol := trade.Symbol
if strings.HasSuffix(symbol, "USDT") {
symbol = strings.TrimSuffix(symbol, "USDT")
}
actionEmoji := "📈"
if strings.Contains(trade.Action, "short") {
actionEmoji = "📉"
}
if strings.Contains(trade.Action, "close") {
actionEmoji = "✅"
}
qtyStr := ""
if trade.Quantity > 0 {
qtyStr = fmt.Sprintf(" %.4f", trade.Quantity)
}
if lang == "zh" {
return fmt.Sprintf("%s 交易已执行!\n%s %s%s", actionEmoji, trade.Action, symbol, qtyStr), true
}
return fmt.Sprintf("%s Trade executed!\n%s %s%s", actionEmoji, trade.Action, symbol, qtyStr), true
}
// marshals trade action to JSON for embedding in responses
func marshalTradeAction(trade *TradeAction) string {
b, _ := json.Marshal(trade)
return string(b)
}

View File

@@ -1,343 +0,0 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"nofx/safe"
"regexp"
"time"
)
type storeUserIDContextKey struct{}
// WithStoreUserID annotates an HTTP request context with the authenticated store user ID.
func WithStoreUserID(ctx context.Context, storeUserID string) context.Context {
return context.WithValue(ctx, storeUserIDContextKey{}, storeUserID)
}
func storeUserIDFromContext(ctx context.Context) string {
if v, ok := ctx.Value(storeUserIDContextKey{}).(string); ok && v != "" {
return v
}
return "default"
}
// validSymbolRe matches only alphanumeric trading symbols (e.g. BTCUSDT, ETH-USD).
var validSymbolRe = regexp.MustCompile(`^[A-Za-z0-9\-_]{1,20}$`)
// validIntervalRe matches only valid kline intervals (e.g. 1m, 5m, 1h, 4h, 1d, 1w).
var validIntervalRe = regexp.MustCompile(`^[0-9]{1,2}[mhHdDwWM]$`)
// binanceClient is a shared HTTP client for proxying Binance API requests.
// Reused across requests to benefit from connection pooling.
var binanceClient = &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 20,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
}
// WebHandler provides HTTP endpoints for the NOFXi agent.
type WebHandler struct {
agent *Agent
logger *slog.Logger
}
func NewWebHandler(agent *Agent, logger *slog.Logger) *WebHandler {
return &WebHandler{agent: agent, logger: logger}
}
// HandleHealth handles GET /api/agent/health.
func (w *WebHandler) HandleHealth(rw http.ResponseWriter, r *http.Request) {
writeJSON(rw, 200, map[string]string{"status": "ok", "agent": "NOFXi", "time": time.Now().Format(time.RFC3339)})
}
// HandleChat handles POST /api/agent/chat.
func (w *WebHandler) HandleChat(rw http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(rw, "method not allowed", 405)
return
}
var req struct {
Message string `json:"message"`
UserID int64 `json:"user_id"`
UserKey string `json:"user_key"`
Lang string `json:"lang"`
}
// Limit request body to 64KB to prevent abuse
if err := json.NewDecoder(io.LimitReader(r.Body, 64*1024)).Decode(&req); err != nil {
writeJSON(rw, 400, map[string]string{"error": "invalid request"})
return
}
if req.Message == "" {
writeJSON(rw, 400, map[string]string{"error": "message required"})
return
}
if req.UserID == 0 {
req.UserID = SessionUserIDFromKey(req.UserKey)
}
msg := req.Message
if req.Lang != "" {
msg = "[lang:" + req.Lang + "] " + msg
}
ctx, cancel := context.WithTimeout(r.Context(), 55*time.Second)
defer cancel()
resp, err := w.agent.HandleMessageForStoreUser(ctx, storeUserIDFromContext(r.Context()), req.UserID, msg)
if err != nil {
w.logger.Error("agent HandleMessage failed", "error", err, "user_id", req.UserID)
writeJSON(rw, 500, map[string]string{"error": "Failed to process message. Please try again."})
return
}
writeJSON(rw, 200, map[string]string{"response": resp})
}
// HandleChatStream handles POST /api/agent/chat/stream — SSE streaming chat.
// Sends server-sent events with types including planning, plan, step_start,
// step_complete, replan, tool, delta, done, error.
func (w *WebHandler) HandleChatStream(rw http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(rw, "method not allowed", 405)
return
}
var req struct {
Message string `json:"message"`
UserID int64 `json:"user_id"`
UserKey string `json:"user_key"`
Lang string `json:"lang"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 64*1024)).Decode(&req); err != nil {
writeJSON(rw, 400, map[string]string{"error": "invalid request"})
return
}
if req.Message == "" {
writeJSON(rw, 400, map[string]string{"error": "message required"})
return
}
if req.UserID == 0 {
req.UserID = SessionUserIDFromKey(req.UserKey)
}
msg := req.Message
if req.Lang != "" {
msg = "[lang:" + req.Lang + "] " + msg
}
// Set SSE headers
rw.Header().Set("Content-Type", "text/event-stream")
rw.Header().Set("Cache-Control", "no-cache")
rw.Header().Set("Connection", "keep-alive")
rw.Header().Set("X-Accel-Buffering", "no") // Disable nginx buffering
rw.WriteHeader(200)
flusher, ok := rw.(http.Flusher)
if !ok {
writeSSE(rw, nil, "error", "streaming not supported")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second)
defer cancel()
resp, err := w.agent.HandleMessageStreamForStoreUser(ctx, storeUserIDFromContext(r.Context()), req.UserID, msg, func(event, data string) {
writeSSE(rw, flusher, event, data)
})
if err != nil {
w.logger.Error("agent HandleMessageStream failed", "error", err, "user_id", req.UserID)
writeSSE(rw, flusher, "error", "Failed to process message. Please try again.")
return
}
// Send final done event with complete response
writeSSE(rw, flusher, "done", resp)
}
// writeSSE writes a single SSE event.
func writeSSE(w http.ResponseWriter, flusher http.Flusher, event, data string) {
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, sseEscape(data))
if flusher != nil {
flusher.Flush()
}
}
// sseEscape escapes newlines in SSE data (each line needs a "data: " prefix).
func sseEscape(s string) string {
// SSE spec: multi-line data uses multiple "data:" lines
// But we use JSON encoding to avoid this complexity
b, _ := json.Marshal(s)
return string(b)
}
// HandleKlines proxies kline data from Binance.
func (w *WebHandler) HandleKlines(rw http.ResponseWriter, r *http.Request) {
symbol := r.URL.Query().Get("symbol")
if symbol == "" {
symbol = "BTCUSDT"
}
interval := r.URL.Query().Get("interval")
if interval == "" {
interval = "1h"
}
if !validSymbolRe.MatchString(symbol) {
writeJSON(rw, 400, map[string]string{"error": "invalid symbol"})
return
}
if !validIntervalRe.MatchString(interval) {
writeJSON(rw, 400, map[string]string{"error": "invalid interval"})
return
}
proxyBinance(rw, r.Context(), fmt.Sprintf("https://fapi.binance.com/fapi/v1/klines?symbol=%s&interval=%s&limit=300", symbol, interval))
}
// HandleTicker proxies ticker data from Binance.
func (w *WebHandler) HandleTicker(rw http.ResponseWriter, r *http.Request) {
symbol := r.URL.Query().Get("symbol")
if symbol == "" {
symbol = "BTCUSDT"
}
if !validSymbolRe.MatchString(symbol) {
writeJSON(rw, 400, map[string]string{"error": "invalid symbol"})
return
}
proxyBinance(rw, r.Context(), fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", symbol))
}
// HandleTickers handles GET /api/agent/tickers?symbols=BTCUSDT,ETHUSDT,SOLUSDT
// Batch endpoint: fetches multiple tickers concurrently, returns array.
func (w *WebHandler) HandleTickers(rw http.ResponseWriter, r *http.Request) {
symbolsParam := r.URL.Query().Get("symbols")
if symbolsParam == "" {
symbolsParam = "BTCUSDT,ETHUSDT,SOLUSDT"
}
// Validate symbols
var symbols []string
for _, s := range splitComma(symbolsParam) {
if validSymbolRe.MatchString(s) {
symbols = append(symbols, s)
}
}
if len(symbols) == 0 {
writeJSON(rw, 400, map[string]string{"error": "no valid symbols"})
return
}
if len(symbols) > 20 {
writeJSON(rw, 400, map[string]string{"error": "max 20 symbols"})
return
}
// Fetch all tickers concurrently with context propagation
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
type result struct {
idx int
data json.RawMessage
}
results := make(chan result, len(symbols))
for i, sym := range symbols {
idx, s := i, sym
safe.GoNamed("ticker-fetch-"+s, func() {
req, err := http.NewRequestWithContext(ctx, "GET",
fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", s), nil)
if err != nil {
results <- result{idx: idx}
return
}
resp, err := binanceClient.Do(req)
if err != nil {
results <- result{idx: idx}
return
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
results <- result{idx: idx}
return
}
body, err := safe.ReadAllLimited(resp.Body, 16*1024)
if err != nil {
results <- result{idx: idx}
return
}
results <- result{idx: idx, data: body}
})
}
// Collect results in order
ordered := make([]json.RawMessage, len(symbols))
for range symbols {
r := <-results
if r.data != nil {
ordered[r.idx] = r.data
}
}
// Filter out nil entries and write response
out := make([]json.RawMessage, 0, len(ordered))
for _, d := range ordered {
if d != nil {
out = append(out, d)
}
}
rw.Header().Set("Content-Type", "application/json")
json.NewEncoder(rw).Encode(out)
}
// commaRe is pre-compiled for splitComma — avoids recompiling on every call.
var commaRe = regexp.MustCompile(`\s*,\s*`)
// splitComma splits a comma-separated string, trims whitespace, skips empty.
func splitComma(s string) []string {
var parts []string
for _, p := range commaRe.Split(s, -1) {
if p != "" {
parts = append(parts, p)
}
}
return parts
}
func proxyBinance(rw http.ResponseWriter, ctx context.Context, url string) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
writeJSON(rw, 500, map[string]string{"error": "failed to create request"})
return
}
resp, err := binanceClient.Do(req)
if err != nil {
// Distinguish client cancellation from upstream failures
if ctx.Err() != nil {
return // Client disconnected, no point writing response
}
writeJSON(rw, 502, map[string]string{"error": "upstream request failed"})
return
}
defer resp.Body.Close()
// Forward upstream error status codes instead of silently proxying bad data
if resp.StatusCode != http.StatusOK {
writeJSON(rw, 502, map[string]string{"error": fmt.Sprintf("upstream returned status %d", resp.StatusCode)})
return
}
rw.Header().Set("Content-Type", "application/json")
// CORS is handled by the gin middleware — no need to set it here
// Limit response body to 2MB to prevent memory exhaustion
io.Copy(rw, io.LimitReader(resp.Body, 2*1024*1024))
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
// CORS is handled by the gin middleware — no need to set it here
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}

View File

@@ -1,521 +0,0 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"nofx/mcp"
)
const (
workflowTaskPending = "pending"
workflowTaskRunning = "running"
workflowTaskCompleted = "completed"
workflowTaskFailed = "failed"
)
type WorkflowTask struct {
ID string `json:"id,omitempty"`
Skill string `json:"skill,omitempty"`
Action string `json:"action,omitempty"`
Request string `json:"request,omitempty"`
DependsOn []string `json:"depends_on,omitempty"`
Status string `json:"status,omitempty"`
Error string `json:"error,omitempty"`
}
type WorkflowSession struct {
UserID int64 `json:"user_id"`
OriginalRequest string `json:"original_request,omitempty"`
Tasks []WorkflowTask `json:"tasks,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
type workflowDecomposition struct {
Tasks []WorkflowTask `json:"tasks"`
}
func workflowSessionConfigKey(userID int64) string {
return fmt.Sprintf("agent_workflow_session_%d", userID)
}
func normalizeWorkflowSession(session WorkflowSession) WorkflowSession {
session.OriginalRequest = strings.TrimSpace(session.OriginalRequest)
normalized := make([]WorkflowTask, 0, len(session.Tasks))
for i, task := range session.Tasks {
task.ID = strings.TrimSpace(task.ID)
if task.ID == "" {
task.ID = fmt.Sprintf("task_%d", i+1)
}
task.Skill = strings.TrimSpace(task.Skill)
task.Action = normalizeAtomicSkillAction(task.Skill, task.Action)
task.Request = strings.TrimSpace(task.Request)
task.DependsOn = cleanStringList(task.DependsOn)
task.Status = strings.TrimSpace(task.Status)
if task.Status == "" {
task.Status = workflowTaskPending
}
task.Error = strings.TrimSpace(task.Error)
if task.Skill == "" || task.Action == "" || task.Request == "" {
continue
}
normalized = append(normalized, task)
}
session.Tasks = normalized
if len(session.Tasks) == 0 {
return WorkflowSession{}
}
if session.UpdatedAt == "" {
session.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
}
return session
}
func (a *Agent) getWorkflowSession(userID int64) WorkflowSession {
if a.store == nil {
return WorkflowSession{}
}
raw, err := a.store.GetSystemConfig(workflowSessionConfigKey(userID))
if err != nil || strings.TrimSpace(raw) == "" {
return WorkflowSession{}
}
var session WorkflowSession
if err := json.Unmarshal([]byte(raw), &session); err != nil {
return WorkflowSession{}
}
return normalizeWorkflowSession(session)
}
func (a *Agent) saveWorkflowSession(userID int64, session WorkflowSession) {
if a.store == nil {
return
}
session = normalizeWorkflowSession(session)
if len(session.Tasks) == 0 {
_ = a.store.SetSystemConfig(workflowSessionConfigKey(userID), "")
return
}
session.UserID = userID
session.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
data, err := json.Marshal(session)
if err != nil {
return
}
_ = a.store.SetSystemConfig(workflowSessionConfigKey(userID), string(data))
}
func (a *Agent) clearWorkflowSession(userID int64) {
if a.store == nil {
return
}
_ = a.store.SetSystemConfig(workflowSessionConfigKey(userID), "")
}
func hasActiveWorkflowSession(session WorkflowSession) bool {
if len(session.Tasks) == 0 {
return false
}
for _, task := range session.Tasks {
if task.Status == workflowTaskPending || task.Status == workflowTaskRunning {
return true
}
}
return false
}
func nextRunnableWorkflowTask(session WorkflowSession) (WorkflowTask, int, bool) {
for i, task := range session.Tasks {
if task.Status != workflowTaskPending && task.Status != workflowTaskRunning {
continue
}
depsReady := true
for _, dep := range task.DependsOn {
ok := false
for _, candidate := range session.Tasks {
if candidate.ID == dep && candidate.Status == workflowTaskCompleted {
ok = true
break
}
}
if !ok {
depsReady = false
break
}
}
if depsReady {
return task, i, true
}
}
return WorkflowTask{}, -1, false
}
func supportedWorkflowSkill(skill, action string) bool {
skill = strings.TrimSpace(skill)
action = normalizeAtomicSkillAction(skill, action)
if skill == "" || action == "" {
return false
}
if _, ok := getSkillDAG(skill, action); ok {
return true
}
switch skill {
case "trader_management", "strategy_management", "model_management", "exchange_management":
switch action {
case "create", "query_list", "query_detail", "query_running", "activate":
return true
}
}
return false
}
func (a *Agent) tryWorkflowIntent(ctx context.Context, storeUserID string, userID int64, lang, text string, onEvent func(event, data string)) (string, bool, error) {
if session := a.getWorkflowSession(userID); hasActiveWorkflowSession(session) {
return a.handleWorkflowSession(ctx, storeUserID, userID, lang, text, session, onEvent)
}
decomposition, err := a.decomposeWorkflowIntent(ctx, userID, lang, text)
if err != nil || len(decomposition.Tasks) <= 1 {
return "", false, err
}
session := WorkflowSession{
UserID: userID,
OriginalRequest: text,
Tasks: decomposition.Tasks,
}
a.saveWorkflowSession(userID, session)
return a.handleWorkflowSession(ctx, storeUserID, userID, lang, text, session, onEvent)
}
func (a *Agent) handleWorkflowSession(ctx context.Context, storeUserID string, userID int64, lang, text string, session WorkflowSession, onEvent func(event, data string)) (string, bool, error) {
if isExplicitFlowAbort(text) {
a.clearSkillSession(userID)
a.clearWorkflowSession(userID)
if lang == "zh" {
return "已取消当前任务流。", true, nil
}
return "Cancelled the current workflow.", true, nil
}
if activeSkill := a.getSkillSession(userID); strings.TrimSpace(activeSkill.Name) != "" {
answer, handled := a.tryHardSkill(ctx, storeUserID, userID, lang, text, onEvent)
if !handled {
return "", false, nil
}
session = a.getWorkflowSession(userID)
if hasActiveWorkflowSession(session) && strings.TrimSpace(a.getSkillSession(userID).Name) == "" {
session = markCurrentWorkflowTask(session, workflowTaskCompleted, "")
a.saveWorkflowSession(userID, session)
if final, done, err := a.maybeAdvanceWorkflow(ctx, storeUserID, userID, lang, session, onEvent); done || err != nil {
if final != "" && answer != "" {
return answer + "\n\n" + final, true, err
}
if answer != "" {
return answer, true, err
}
return final, true, err
}
}
return answer, true, nil
}
return a.maybeAdvanceWorkflow(ctx, storeUserID, userID, lang, session, onEvent)
}
func (a *Agent) maybeAdvanceWorkflow(ctx context.Context, storeUserID string, userID int64, lang string, session WorkflowSession, onEvent func(event, data string)) (string, bool, error) {
task, index, ok := nextRunnableWorkflowTask(session)
if !ok {
summary := a.generateWorkflowSummary(ctx, userID, lang, session)
a.clearWorkflowSession(userID)
if summary == "" {
if lang == "zh" {
summary = "已完成当前任务流。"
} else {
summary = "Completed the current workflow."
}
}
if onEvent != nil {
onEvent(StreamEventPlan, summary)
onEvent(StreamEventDelta, summary)
}
return summary, true, nil
}
session.Tasks[index].Status = workflowTaskRunning
a.saveWorkflowSession(userID, session)
taskSession := skillSession{Name: task.Skill, Action: task.Action, Phase: "collecting"}
a.saveSkillSession(userID, taskSession)
if onEvent != nil {
onEvent(StreamEventPlan, a.formatWorkflowStatus(lang, session))
onEvent(StreamEventTool, "workflow:"+task.Skill+":"+task.Action)
}
answer, handled := a.tryHardSkill(ctx, storeUserID, userID, lang, task.Request, onEvent)
if !handled {
session.Tasks[index].Status = workflowTaskFailed
session.Tasks[index].Error = "task_not_handled"
a.saveWorkflowSession(userID, session)
return "", false, nil
}
if strings.TrimSpace(a.getSkillSession(userID).Name) == "" {
session = a.getWorkflowSession(userID)
session = markCurrentWorkflowTask(session, workflowTaskCompleted, "")
a.saveWorkflowSession(userID, session)
if more, ok, err := a.maybeAdvanceWorkflow(ctx, storeUserID, userID, lang, session, onEvent); ok || err != nil {
if answer != "" && more != "" {
return answer + "\n\n" + more, true, err
}
if answer != "" {
return answer, true, err
}
return more, true, err
}
}
return answer, true, nil
}
func markCurrentWorkflowTask(session WorkflowSession, status, errMsg string) WorkflowSession {
for i := range session.Tasks {
if session.Tasks[i].Status == workflowTaskRunning {
session.Tasks[i].Status = status
session.Tasks[i].Error = strings.TrimSpace(errMsg)
return session
}
}
return session
}
func (a *Agent) formatWorkflowStatus(lang string, session WorkflowSession) string {
parts := make([]string, 0, len(session.Tasks))
for _, task := range session.Tasks {
label := task.Request
if label == "" {
label = task.Skill + ":" + task.Action
}
switch task.Status {
case workflowTaskCompleted:
label = "✓ " + label
case workflowTaskRunning:
label = "→ " + label
default:
label = "· " + label
}
parts = append(parts, label)
}
if lang == "zh" {
return "任务流:" + strings.Join(parts, " | ")
}
return "Workflow: " + strings.Join(parts, " | ")
}
func (a *Agent) generateWorkflowSummary(ctx context.Context, userID int64, lang string, session WorkflowSession) string {
completed := make([]string, 0, len(session.Tasks))
for _, task := range session.Tasks {
if task.Status == workflowTaskCompleted {
completed = append(completed, task.Request)
}
}
if len(completed) == 0 {
return ""
}
if a.aiClient == nil {
if lang == "zh" {
return "已完成这些任务:" + strings.Join(completed, "")
}
return "Completed these tasks: " + strings.Join(completed, "; ")
}
stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout)
defer cancel()
systemPrompt := `You are summarizing a finished workflow for NOFXi.
Return one short user-facing summary in the user's language.
Do not mention internal DAG, scheduler, or JSON.`
userPrompt := fmt.Sprintf("Language: %s\nOriginal request: %s\nCompleted tasks:\n- %s", lang, session.OriginalRequest, strings.Join(completed, "\n- "))
raw, err := a.aiClient.CallWithRequest(&mcp.Request{
Messages: []mcp.Message{
mcp.NewSystemMessage(systemPrompt),
mcp.NewUserMessage(userPrompt),
},
Ctx: stageCtx,
})
if err != nil {
if lang == "zh" {
return "已完成这些任务:" + strings.Join(completed, "")
}
return "Completed these tasks: " + strings.Join(completed, "; ")
}
return strings.TrimSpace(raw)
}
func (a *Agent) decomposeWorkflowIntent(ctx context.Context, userID int64, lang, text string) (workflowDecomposition, error) {
if !looksLikeMultiTaskIntent(text) {
return workflowDecomposition{}, nil
}
if a.aiClient != nil {
if dec, err := a.decomposeWorkflowIntentWithLLM(ctx, userID, lang, text); err == nil && len(dec.Tasks) > 1 {
return dec, nil
}
}
return a.decomposeWorkflowIntentFallback(text), nil
}
func looksLikeMultiTaskIntent(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" {
return false
}
connectors := []string{"", ",", "然后", "再", "并且", "并", "同时", "and", "then"}
count := 0
for _, c := range connectors {
if strings.Contains(lower, c) {
count++
}
}
return count > 0
}
func (a *Agent) decomposeWorkflowIntentWithLLM(ctx context.Context, userID int64, lang, text string) (workflowDecomposition, error) {
stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout)
defer cancel()
systemPrompt := `You decompose one NOFXi user request into a small task graph.
Return JSON only. No markdown.
Only use these skills: trader_management, strategy_management, model_management, exchange_management.
Only use one atomic action per task.
Each task must include:
- id
- skill
- action
- request
- depends_on (array, may be empty)
If the request is effectively a single task, return one task only.`
userPrompt := fmt.Sprintf("Language: %s\nUser request: %s", lang, text)
raw, err := a.aiClient.CallWithRequest(&mcp.Request{
Messages: []mcp.Message{
mcp.NewSystemMessage(systemPrompt),
mcp.NewUserMessage(userPrompt),
},
Ctx: stageCtx,
})
if err != nil {
return workflowDecomposition{}, err
}
return parseWorkflowDecomposition(raw)
}
func parseWorkflowDecomposition(raw string) (workflowDecomposition, error) {
raw = strings.TrimSpace(raw)
raw = strings.TrimPrefix(raw, "```json")
raw = strings.TrimPrefix(raw, "```")
raw = strings.TrimSuffix(raw, "```")
raw = strings.TrimSpace(raw)
var out workflowDecomposition
if err := json.Unmarshal([]byte(raw), &out); err == nil {
out = normalizeWorkflowDecomposition(out)
return out, nil
}
start := strings.Index(raw, "{")
end := strings.LastIndex(raw, "}")
if start >= 0 && end > start {
if err := json.Unmarshal([]byte(raw[start:end+1]), &out); err == nil {
out = normalizeWorkflowDecomposition(out)
return out, nil
}
}
return workflowDecomposition{}, fmt.Errorf("invalid workflow json")
}
func normalizeWorkflowDecomposition(out workflowDecomposition) workflowDecomposition {
normalized := make([]WorkflowTask, 0, len(out.Tasks))
for i, task := range out.Tasks {
task.ID = strings.TrimSpace(task.ID)
if task.ID == "" {
task.ID = fmt.Sprintf("task_%d", i+1)
}
task.Skill = strings.TrimSpace(task.Skill)
task.Action = normalizeAtomicSkillAction(task.Skill, task.Action)
task.Request = strings.TrimSpace(task.Request)
task.DependsOn = cleanStringList(task.DependsOn)
if !supportedWorkflowSkill(task.Skill, task.Action) || task.Request == "" {
continue
}
task.Status = workflowTaskPending
normalized = append(normalized, task)
}
out.Tasks = normalized
return out
}
func (a *Agent) decomposeWorkflowIntentFallback(text string) workflowDecomposition {
segments := splitWorkflowSegments(text)
tasks := make([]WorkflowTask, 0, len(segments))
for i, segment := range segments {
task, ok := classifyWorkflowTask(segment)
if !ok {
continue
}
task.ID = fmt.Sprintf("task_%d", i+1)
task.Status = workflowTaskPending
if len(tasks) > 0 {
task.DependsOn = []string{tasks[len(tasks)-1].ID}
}
tasks = append(tasks, task)
}
return workflowDecomposition{Tasks: tasks}
}
func splitWorkflowSegments(text string) []string {
parts := []string{strings.TrimSpace(text)}
separators := []string{"", ",", "然后", "再", "并且", "同时", " and then ", " then ", " and "}
for _, sep := range separators {
next := make([]string, 0, len(parts))
for _, part := range parts {
split := strings.Split(part, sep)
for _, candidate := range split {
candidate = strings.TrimSpace(candidate)
if candidate != "" {
next = append(next, candidate)
}
}
}
parts = next
}
return parts
}
func classifyWorkflowTask(text string) (WorkflowTask, bool) {
segment := strings.TrimSpace(text)
if segment == "" {
return WorkflowTask{}, false
}
switch {
case detectCreateTraderSkill(segment):
return WorkflowTask{Skill: "trader_management", Action: "create", Request: segment}, true
case detectTraderManagementIntent(segment):
action := normalizeAtomicSkillAction("trader_management", detectManagementAction(segment, "trader"))
if supportedWorkflowSkill("trader_management", action) {
return WorkflowTask{Skill: "trader_management", Action: action, Request: segment}, true
}
case detectExchangeManagementIntent(segment):
action := normalizeAtomicSkillAction("exchange_management", detectManagementAction(segment, "exchange"))
if supportedWorkflowSkill("exchange_management", action) {
return WorkflowTask{Skill: "exchange_management", Action: action, Request: segment}, true
}
case detectModelManagementIntent(segment):
action := normalizeAtomicSkillAction("model_management", detectManagementAction(segment, "model"))
if supportedWorkflowSkill("model_management", action) {
return WorkflowTask{Skill: "model_management", Action: action, Request: segment}, true
}
case detectStrategyManagementIntent(segment):
action := normalizeAtomicSkillAction("strategy_management", detectManagementAction(segment, "strategy"))
if action == "" && wantsStrategyDetails(segment) {
action = "query_detail"
}
if supportedWorkflowSkill("strategy_management", action) {
return WorkflowTask{Skill: "strategy_management", Action: action, Request: segment}, true
}
}
return WorkflowTask{}, false
}

View File

@@ -1,37 +0,0 @@
package agent
import "testing"
func TestSplitWorkflowSegments(t *testing.T) {
got := splitWorkflowSegments("把策略删了,再把交易所改名")
if len(got) != 2 {
t.Fatalf("expected 2 segments, got %d: %#v", len(got), got)
}
}
func TestClassifyWorkflowTask(t *testing.T) {
task, ok := classifyWorkflowTask("把策略删了")
if !ok {
t.Fatal("expected task")
}
if task.Skill != "strategy_management" || task.Action != "delete" {
t.Fatalf("unexpected task: %+v", task)
}
}
func TestFallbackWorkflowDecompositionBuildsTwoTasks(t *testing.T) {
a := &Agent{}
out := a.decomposeWorkflowIntentFallback("把策略删了,再把交易所改名")
if len(out.Tasks) != 2 {
t.Fatalf("expected 2 tasks, got %d", len(out.Tasks))
}
if out.Tasks[0].Skill != "strategy_management" {
t.Fatalf("unexpected first task: %+v", out.Tasks[0])
}
if out.Tasks[1].Skill != "exchange_management" {
t.Fatalf("unexpected second task: %+v", out.Tasks[1])
}
if len(out.Tasks[1].DependsOn) != 1 || out.Tasks[1].DependsOn[0] != out.Tasks[0].ID {
t.Fatalf("expected dependency on first task, got %+v", out.Tasks[1].DependsOn)
}
}

View File

@@ -1,106 +0,0 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"nofx/agent"
"github.com/gin-gonic/gin"
)
type agentPreferencePayload struct {
Text string `json:"text"`
}
func (s *Server) handleGetAgentPreferences(c *gin.Context) {
uid := agent.SessionUserIDFromKey(c.GetString("user_id"))
raw, err := s.store.GetSystemConfig(agent.PreferencesConfigKey(uid))
if err != nil || strings.TrimSpace(raw) == "" {
c.JSON(http.StatusOK, gin.H{"preferences": []agent.PersistentPreference{}})
return
}
var prefs []agent.PersistentPreference
if err := json.Unmarshal([]byte(raw), &prefs); err != nil {
c.JSON(http.StatusOK, gin.H{"preferences": []agent.PersistentPreference{}})
return
}
c.JSON(http.StatusOK, gin.H{"preferences": prefs})
}
func (s *Server) handleCreateAgentPreference(c *gin.Context) {
uid := agent.SessionUserIDFromKey(c.GetString("user_id"))
var req agentPreferencePayload
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Text) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "text required"})
return
}
created, err := agent.NewPersistentPreference(req.Text)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
prefs := s.loadAgentPreferences(uid)
prefs = append([]agent.PersistentPreference{created}, prefs...)
if len(prefs) > 20 {
prefs = prefs[:20]
}
if err := s.saveAgentPreferences(uid, prefs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save preference"})
return
}
c.JSON(http.StatusOK, gin.H{"preferences": prefs})
}
func (s *Server) handleDeleteAgentPreference(c *gin.Context) {
uid := agent.SessionUserIDFromKey(c.GetString("user_id"))
id := strings.TrimSpace(c.Param("id"))
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "id required"})
return
}
prefs := s.loadAgentPreferences(uid)
filtered := prefs[:0]
for _, pref := range prefs {
if pref.ID != id {
filtered = append(filtered, pref)
}
}
if err := s.saveAgentPreferences(uid, filtered); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete preference"})
return
}
c.JSON(http.StatusOK, gin.H{"preferences": filtered})
}
func (s *Server) loadAgentPreferences(userID int64) []agent.PersistentPreference {
raw, err := s.store.GetSystemConfig(agent.PreferencesConfigKey(userID))
if err != nil || strings.TrimSpace(raw) == "" {
return []agent.PersistentPreference{}
}
var prefs []agent.PersistentPreference
if err := json.Unmarshal([]byte(raw), &prefs); err != nil {
return []agent.PersistentPreference{}
}
return prefs
}
func (s *Server) saveAgentPreferences(userID int64, prefs []agent.PersistentPreference) error {
data, err := json.Marshal(prefs)
if err != nil {
return err
}
return s.store.SetSystemConfig(agent.PreferencesConfigKey(userID), string(data))
}

View File

@@ -1,26 +0,0 @@
package api
import (
"nofx/agent"
"github.com/gin-gonic/gin"
)
// RegisterAgentHandler registers NOFXi agent API routes on the main router.
// Chat endpoint requires authentication; market data endpoints are public.
func (s *Server) RegisterAgentHandler(h *agent.WebHandler) {
// Chat requires auth — can trigger trades and access account data
s.router.POST("/api/agent/chat", s.authMiddleware(), func(c *gin.Context) {
req := c.Request.WithContext(agent.WithStoreUserID(c.Request.Context(), c.GetString("user_id")))
h.HandleChat(c.Writer, req)
})
s.router.POST("/api/agent/chat/stream", s.authMiddleware(), func(c *gin.Context) {
req := c.Request.WithContext(agent.WithStoreUserID(c.Request.Context(), c.GetString("user_id")))
h.HandleChatStream(c.Writer, req)
})
// Public endpoints — read-only market data
s.router.GET("/api/agent/health", gin.WrapF(h.HandleHealth))
s.router.GET("/api/agent/klines", gin.WrapF(h.HandleKlines))
s.router.GET("/api/agent/ticker", gin.WrapF(h.HandleTicker))
s.router.GET("/api/agent/tickers", gin.WrapF(h.HandleTickers))
}

View File

@@ -1,7 +1,6 @@
package api
import (
"log"
"net/http"
"nofx/config"
"nofx/crypto"
@@ -53,28 +52,16 @@ func (h *CryptoHandler) HandleGetPublicKey(c *gin.Context) {
})
}
// ==================== Encrypted Data Decryption Endpoint ====================
// HandleDecryptSensitiveData Decrypt encrypted data sent from client
func (h *CryptoHandler) HandleDecryptSensitiveData(c *gin.Context) {
var payload crypto.EncryptedPayload
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
// Decrypt
decrypted, err := h.cryptoService.DecryptSensitiveData(&payload)
if err != nil {
log.Printf("❌ Decryption failed: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Decryption failed"})
return
}
c.JSON(http.StatusOK, map[string]string{
"plaintext": decrypted,
})
}
// ==================== Encrypted Data Decryption ====================
//
// SECURITY: there is deliberately NO public decrypt endpoint. Transport
// encryption is one-directional — clients encrypt sensitive fields to the
// server's RSA public key and the authenticated config-update handlers
// (handleUpdateModelConfigs / handleUpdateExchangeConfigs / handleCreateExchange)
// decrypt them server-side via cryptoService.DecryptSensitiveData. Exposing a
// generic decrypt route would turn the server into a decryption oracle that any
// unauthenticated caller could use to recover the plaintext of a captured
// ciphertext, defeating the entire transport-encryption layer.
// ==================== Audit Log Query Endpoint ====================

View File

@@ -319,30 +319,24 @@ func accountAssetForExchange(exchangeType string) string {
}
func missingExchangeCredentials(exchangeCfg *store.Exchange) (status string, code string, message string, missing bool) {
switch exchangeCfg.ExchangeType {
case "binance", "bybit", "gate", "indodax":
if exchangeCfg.APIKey == "" || exchangeCfg.SecretKey == "" {
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "API key and secret key are required", true
}
case "okx", "bitget", "kucoin":
if exchangeCfg.APIKey == "" || exchangeCfg.SecretKey == "" || exchangeCfg.Passphrase == "" {
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "API key, secret key, and passphrase are required", true
}
case "hyperliquid":
if exchangeCfg.APIKey == "" || exchangeCfg.HyperliquidWalletAddr == "" {
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "Private key and wallet address are required", true
}
case "aster":
if exchangeCfg.AsterUser == "" || exchangeCfg.AsterSigner == "" || exchangeCfg.AsterPrivateKey == "" {
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "Aster user, signer, and private key are required", true
}
case "lighter":
if exchangeCfg.LighterWalletAddr == "" || exchangeCfg.LighterAPIKeyPrivateKey == "" {
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "Wallet address and API key private key are required", true
}
default:
missingFields := store.MissingRequiredExchangeCredentialFields(
exchangeCfg.ExchangeType,
string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey),
string(exchangeCfg.Passphrase),
exchangeCfg.HyperliquidWalletAddr,
exchangeCfg.AsterUser,
exchangeCfg.AsterSigner,
string(exchangeCfg.AsterPrivateKey),
exchangeCfg.LighterWalletAddr,
string(exchangeCfg.LighterAPIKeyPrivateKey),
)
if len(missingFields) > 0 {
if len(missingFields) == 1 && missingFields[0] == "exchange_type" {
return exchangeAccountStatusUnavailable, "UNSUPPORTED_EXCHANGE", "Unsupported exchange type", true
}
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "Missing required fields: " + strings.Join(missingFields, ", "), true
}
return "", "", "", false
}

View File

@@ -10,6 +10,7 @@ import (
"nofx/crypto"
"nofx/logger"
"nofx/security"
"nofx/store"
"nofx/wallet"
"github.com/gin-gonic/gin"
@@ -37,13 +38,19 @@ type SafeModelConfig struct {
BalanceUSDC string `json:"balanceUsdc,omitempty"`
}
type UpdateModelConfigRequest struct {
Models map[string]struct {
// ModelConfigUpdate is a single model's update payload. It is a named type
// (rather than an inline anonymous struct) so the log-sanitizer in utils.go is
// guaranteed to stay in sync with this shape — a mismatch there is what let
// plaintext credentials reach the logs previously.
type ModelConfigUpdate struct {
Enabled bool `json:"enabled"`
APIKey string `json:"api_key"`
CustomAPIURL string `json:"custom_api_url"`
CustomModelName string `json:"custom_model_name"`
} `json:"models"`
}
type UpdateModelConfigRequest struct {
Models map[string]ModelConfigUpdate `json:"models"`
}
// handleGetModelConfigs Get AI model configurations
@@ -77,8 +84,11 @@ func (s *Server) handleGetModelConfigs(c *gin.Context) {
logger.Infof("✅ Found %d AI model configs", len(models))
// Convert to safe response structure, remove sensitive information
safeModels := make([]SafeModelConfig, len(models))
for i, model := range models {
safeModels := make([]SafeModelConfig, 0, len(models))
for _, model := range models {
if !store.IsVisibleAIModel(model) {
continue
}
safeModel := SafeModelConfig{
ID: model.ID,
Name: model.Name,
@@ -100,7 +110,23 @@ func (s *Server) handleGetModelConfigs(c *gin.Context) {
}
}
safeModels[i] = safeModel
safeModels = append(safeModels, safeModel)
}
if len(safeModels) == 0 {
logger.Infof("⚠️ No visible AI models in database, returning defaults")
defaultModels := []SafeModelConfig{
{ID: "deepseek", Name: "DeepSeek AI", Provider: "deepseek", 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)
return
}
c.JSON(http.StatusOK, safeModels)
@@ -205,7 +231,7 @@ func (s *Server) handleUpdateModelConfigs(c *gin.Context) {
// Don't return error here since model config was successfully updated to database
}
logger.Infof("✓ AI model config updated: %+v", req.Models)
logger.Infof("✓ AI model config updated: %+v", SanitizeModelConfigForLog(req.Models))
c.JSON(http.StatusOK, gin.H{"message": "Model configuration updated"})
}
@@ -217,10 +243,12 @@ func (s *Server) handleGetSupportedModels(c *gin.Context) {
{"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.1-pro"},
{"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"},
}

View File

@@ -119,12 +119,25 @@ func (s *Server) handleCompetition(c *gin.Context) {
c.JSON(http.StatusOK, competition)
}
// handleEquityHistory Return rate historical data
// Query directly from database, not dependent on trader in memory (so historical data can be retrieved after restart)
// handleEquityHistory returns equity history for a trader. This endpoint is
// PUBLIC (used by the competition leaderboard), so it cannot use the
// authenticated getTraderFromQuery helper. Instead, it validates that the
// requested trader has explicitly opted into the public competition via
// show_in_competition=true. Traders without that flag are not exposed.
func (s *Server) handleEquityHistory(c *gin.Context) {
_, traderID, err := s.getTraderFromQuery(c)
if err != nil {
SafeBadRequest(c, "Invalid trader ID")
traderID := c.Query("trader_id")
if traderID == "" {
SafeBadRequest(c, "trader_id is required")
return
}
trader, err := s.store.Trader().GetByID(traderID)
if err != nil || trader == nil {
SafeNotFound(c, "Trader")
return
}
if !trader.ShowInCompetition {
// Do not leak that a private trader exists; report not found.
SafeNotFound(c, "Trader")
return
}
@@ -152,34 +165,80 @@ func (s *Server) handleEquityHistory(c *gin.Context) {
MarginUsedPct float64 `json:"margin_used_pct"` // Margin used percentage
}
// Use the balance of the first record as initial balance to calculate return rate
initialBalance := snapshots[0].Balance
initialBalance := trader.InitialBalance
if initialBalance <= 0 {
initialBalance = snapshots[0].TotalEquity
}
if initialBalance == 0 {
initialBalance = 1 // Avoid division by zero
}
var history []EquityPoint
var lastSnapshotTime time.Time
for _, snap := range snapshots {
// Calculate PnL percentage
totalPnL := snap.TotalEquity - initialBalance
totalPnLPct := 0.0
if initialBalance > 0 {
totalPnLPct = (snap.UnrealizedPnL / initialBalance) * 100
totalPnLPct = (totalPnL / initialBalance) * 100
}
history = append(history, EquityPoint{
Timestamp: snap.Timestamp.Format("2006-01-02 15:04:05"),
TotalEquity: snap.TotalEquity,
AvailableBalance: snap.Balance,
TotalPnL: snap.UnrealizedPnL,
AvailableBalance: equitySnapshotAvailableBalance(snap),
TotalPnL: totalPnL,
TotalPnLPct: totalPnLPct,
PositionCount: snap.PositionCount,
MarginUsedPct: snap.MarginUsedPct,
})
if snap.Timestamp.After(lastSnapshotTime) {
lastSnapshotTime = snap.Timestamp
}
}
if runtimeTrader, err := s.traderManager.GetTrader(traderID); err == nil {
if accountInfo, err := runtimeTrader.GetAccountInfo(); err == nil && time.Since(lastSnapshotTime) > 30*time.Second {
totalEquity := floatFromMap(accountInfo, "total_equity")
totalPnL := totalEquity - initialBalance
totalPnLPct := 0.0
if initialBalance > 0 {
totalPnLPct = (totalPnL / initialBalance) * 100
}
history = append(history, EquityPoint{
Timestamp: time.Now().UTC().Format("2006-01-02 15:04:05"),
TotalEquity: totalEquity,
AvailableBalance: floatFromMap(accountInfo, "available_balance"),
TotalPnL: totalPnL,
TotalPnLPct: totalPnLPct,
PositionCount: int(floatFromMap(accountInfo, "position_count")),
MarginUsedPct: floatFromMap(accountInfo, "margin_used_pct"),
})
}
}
c.JSON(http.StatusOK, history)
}
func equitySnapshotAvailableBalance(snap *store.EquitySnapshot) float64 {
if snap == nil {
return 0
}
if snap.AvailableBalance != 0 || snap.PositionCount > 0 {
return snap.AvailableBalance
}
return snap.Balance
}
func floatFromMap(values map[string]interface{}, key string) float64 {
if value, ok := values[key].(float64); ok {
return value
}
if value, ok := values[key].(int); ok {
return float64(value)
}
return 0
}
// handlePublicTraderList Get public trader list (no authentication required)
func (s *Server) handlePublicTraderList(c *gin.Context) {
// Get trader information from all users
@@ -373,16 +432,18 @@ func (s *Server) getEquityHistoryForTraders(traderIDs []string, hours int) map[s
history := make([]map[string]interface{}, 0, len(snapshots)+1)
var lastSnapshotTime time.Time
for _, snap := range snapshots {
totalPnL := snap.TotalEquity - initialBalance
// Calculate PnL percentage: (current_equity - initial_balance) / initial_balance * 100
pnlPct := 0.0
if initialBalance > 0 {
pnlPct = (snap.TotalEquity - initialBalance) / initialBalance * 100
pnlPct = totalPnL / initialBalance * 100
}
history = append(history, map[string]interface{}{
"timestamp": snap.Timestamp,
"total_equity": snap.TotalEquity,
"total_pnl": snap.UnrealizedPnL,
"available_balance": equitySnapshotAvailableBalance(snap),
"total_pnl": totalPnL,
"total_pnl_pct": pnlPct,
"balance": snap.Balance,
})
@@ -397,18 +458,9 @@ func (s *Server) getEquityHistoryForTraders(traderIDs []string, hours int) map[s
if accountInfo, err := trader.GetAccountInfo(); err == nil {
// Only append if it's been more than 30 seconds since last snapshot
if now.Sub(lastSnapshotTime) > 30*time.Second {
totalEquity := 0.0
if v, ok := accountInfo["total_equity"].(float64); ok {
totalEquity = v
}
totalPnL := 0.0
if v, ok := accountInfo["total_pnl"].(float64); ok {
totalPnL = v
}
walletBalance := 0.0
if v, ok := accountInfo["wallet_balance"].(float64); ok {
walletBalance = v
}
totalEquity := floatFromMap(accountInfo, "total_equity")
totalPnL := totalEquity - initialBalance
walletBalance := floatFromMap(accountInfo, "wallet_balance")
pnlPct := 0.0
if initialBalance > 0 {
pnlPct = (totalEquity - initialBalance) / initialBalance * 100
@@ -417,6 +469,7 @@ func (s *Server) getEquityHistoryForTraders(traderIDs []string, hours int) map[s
history = append(history, map[string]interface{}{
"timestamp": now,
"total_equity": totalEquity,
"available_balance": floatFromMap(accountInfo, "available_balance"),
"total_pnl": totalPnL,
"total_pnl_pct": pnlPct,
"balance": walletBalance,

View File

@@ -4,10 +4,12 @@ import (
"encoding/json"
"fmt"
"net/http"
"strings"
"nofx/config"
"nofx/crypto"
"nofx/logger"
"nofx/store"
"github.com/gin-gonic/gin"
)
@@ -30,22 +32,58 @@ type SafeExchangeConfig struct {
Name string `json:"name"` // Display name
Type string `json:"type"` // "cex" or "dex"
Enabled bool `json:"enabled"`
HasAPIKey bool `json:"has_api_key"`
HasSecretKey bool `json:"has_secret_key"`
HasPassphrase bool `json:"has_passphrase"`
Testnet bool `json:"testnet,omitempty"`
HyperliquidWalletAddr string `json:"hyperliquidWalletAddr"` // Hyperliquid wallet address (not sensitive)
HyperliquidUnifiedAcct bool `json:"hyperliquidUnifiedAccount"`
HyperliquidBuilderApproved bool `json:"hyperliquidBuilderApproved"`
HasAsterPrivateKey bool `json:"has_aster_private_key"`
AsterUser string `json:"asterUser"` // Aster username (not sensitive)
AsterSigner string `json:"asterSigner"` // Aster signer (not sensitive)
LighterWalletAddr string `json:"lighterWalletAddr"` // LIGHTER wallet address (not sensitive)
HasLighterPrivateKey bool `json:"has_lighter_private_key"`
HasLighterAPIKey bool `json:"has_lighter_api_key_private_key"`
}
type UpdateExchangeConfigRequest struct {
Exchanges map[string]struct {
func safeExchangeConfigFromStore(exchange *store.Exchange) SafeExchangeConfig {
return SafeExchangeConfig{
ID: exchange.ID,
ExchangeType: exchange.ExchangeType,
AccountName: exchange.AccountName,
Name: exchange.Name,
Type: exchange.Type,
Enabled: exchange.Enabled,
HasAPIKey: exchange.APIKey != "",
HasSecretKey: exchange.SecretKey != "",
HasPassphrase: exchange.Passphrase != "",
Testnet: exchange.Testnet,
HyperliquidWalletAddr: exchange.HyperliquidWalletAddr,
HyperliquidUnifiedAcct: exchange.HyperliquidUnifiedAcct,
HyperliquidBuilderApproved: exchange.HyperliquidBuilderApproved,
HasAsterPrivateKey: exchange.AsterPrivateKey != "",
AsterUser: exchange.AsterUser,
AsterSigner: exchange.AsterSigner,
LighterWalletAddr: exchange.LighterWalletAddr,
HasLighterPrivateKey: exchange.LighterPrivateKey != "",
HasLighterAPIKey: exchange.LighterAPIKeyPrivateKey != "",
}
}
// ExchangeConfigUpdate is a single exchange account's update payload. It is a
// named type (rather than an inline anonymous struct) so the log-sanitizer in
// utils.go is guaranteed to cover every sensitive field — a drift between the
// two shapes is what let passphrases / private keys reach the logs previously.
type ExchangeConfigUpdate struct {
Enabled bool `json:"enabled"`
APIKey string `json:"api_key"`
SecretKey string `json:"secret_key"`
Passphrase string `json:"passphrase"` // OKX specific
Testnet bool `json:"testnet"`
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
HyperliquidUnifiedAcct bool `json:"hyperliquid_unified_account"` // Unified Account mode
HyperliquidUnifiedAcct *bool `json:"hyperliquid_unified_account"` // Unified Account mode
HyperliquidBuilderApproved *bool `json:"hyperliquid_builder_approved"`
AsterUser string `json:"aster_user"`
AsterSigner string `json:"aster_signer"`
AsterPrivateKey string `json:"aster_private_key"`
@@ -53,7 +91,10 @@ type UpdateExchangeConfigRequest struct {
LighterPrivateKey string `json:"lighter_private_key"`
LighterAPIKeyPrivateKey string `json:"lighter_api_key_private_key"`
LighterAPIKeyIndex int `json:"lighter_api_key_index"`
} `json:"exchanges"`
}
type UpdateExchangeConfigRequest struct {
Exchanges map[string]ExchangeConfigUpdate `json:"exchanges"`
}
// CreateExchangeRequest request structure for creating a new exchange account
@@ -66,7 +107,8 @@ type CreateExchangeRequest struct {
Passphrase string `json:"passphrase"`
Testnet bool `json:"testnet"`
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
HyperliquidUnifiedAcct bool `json:"hyperliquid_unified_account"` // Unified Account mode: Spot as Perp collateral
HyperliquidUnifiedAcct *bool `json:"hyperliquid_unified_account"` // Unified Account mode: Spot as Perp collateral
HyperliquidBuilderApproved bool `json:"hyperliquid_builder_approved"`
AsterUser string `json:"aster_user"`
AsterSigner string `json:"aster_signer"`
AsterPrivateKey string `json:"aster_private_key"`
@@ -96,26 +138,30 @@ func (s *Server) handleGetExchangeConfigs(c *gin.Context) {
logger.Infof("✅ Found %d exchange configs", len(exchanges))
// Convert to safe response structure, remove sensitive information
safeExchanges := make([]SafeExchangeConfig, len(exchanges))
for i, exchange := range exchanges {
safeExchanges[i] = SafeExchangeConfig{
ID: exchange.ID,
ExchangeType: exchange.ExchangeType,
AccountName: exchange.AccountName,
Name: exchange.Name,
Type: exchange.Type,
Enabled: exchange.Enabled,
Testnet: exchange.Testnet,
HyperliquidWalletAddr: exchange.HyperliquidWalletAddr,
AsterUser: exchange.AsterUser,
AsterSigner: exchange.AsterSigner,
LighterWalletAddr: exchange.LighterWalletAddr,
safeExchanges := make([]SafeExchangeConfig, 0, len(exchanges))
for _, exchange := range exchanges {
if !store.IsVisibleExchange(exchange) {
continue
}
safeExchanges = append(safeExchanges, safeExchangeConfigFromStore(exchange))
}
c.JSON(http.StatusOK, safeExchanges)
}
func effectiveHyperliquidUnifiedAccount(exchangeType string, requested *bool, fallback ...bool) bool {
if requested != nil {
return *requested
}
if strings.EqualFold(exchangeType, "hyperliquid") {
if len(fallback) > 0 {
return fallback[0]
}
return true
}
return false
}
// handleUpdateExchangeConfigs Update exchange configurations (supports both encrypted and plain text based on config)
func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
userID := c.GetString("user_id")
@@ -179,13 +225,83 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
// Update each exchange's configuration and track traders that need reload
tradersToReload := make(map[string]bool)
for exchangeID, exchangeData := range req.Exchanges {
existing, err := s.store.Exchange().GetByID(userID, exchangeID)
if err != nil {
SafeInternalError(c, fmt.Sprintf("Load exchange %s", exchangeID), err)
return
}
effectiveAPIKey := strings.TrimSpace(exchangeData.APIKey)
if effectiveAPIKey == "" {
effectiveAPIKey = strings.TrimSpace(string(existing.APIKey))
}
effectiveSecretKey := strings.TrimSpace(exchangeData.SecretKey)
if effectiveSecretKey == "" {
effectiveSecretKey = strings.TrimSpace(string(existing.SecretKey))
}
effectivePassphrase := strings.TrimSpace(exchangeData.Passphrase)
if effectivePassphrase == "" {
effectivePassphrase = strings.TrimSpace(string(existing.Passphrase))
}
effectiveAsterPrivateKey := strings.TrimSpace(exchangeData.AsterPrivateKey)
if effectiveAsterPrivateKey == "" {
effectiveAsterPrivateKey = strings.TrimSpace(string(existing.AsterPrivateKey))
}
effectiveLighterAPIKeyPrivateKey := strings.TrimSpace(exchangeData.LighterAPIKeyPrivateKey)
if effectiveLighterAPIKeyPrivateKey == "" {
effectiveLighterAPIKeyPrivateKey = strings.TrimSpace(string(existing.LighterAPIKeyPrivateKey))
}
effectiveHyperliquidWalletAddr := strings.TrimSpace(exchangeData.HyperliquidWalletAddr)
if effectiveHyperliquidWalletAddr == "" {
effectiveHyperliquidWalletAddr = strings.TrimSpace(existing.HyperliquidWalletAddr)
}
effectiveAsterUser := strings.TrimSpace(exchangeData.AsterUser)
if effectiveAsterUser == "" {
effectiveAsterUser = strings.TrimSpace(existing.AsterUser)
}
effectiveAsterSigner := strings.TrimSpace(exchangeData.AsterSigner)
if effectiveAsterSigner == "" {
effectiveAsterSigner = strings.TrimSpace(existing.AsterSigner)
}
effectiveLighterWalletAddr := strings.TrimSpace(exchangeData.LighterWalletAddr)
if effectiveLighterWalletAddr == "" {
effectiveLighterWalletAddr = strings.TrimSpace(existing.LighterWalletAddr)
}
effectiveHyperliquidBuilderApproved := existing.HyperliquidBuilderApproved
if exchangeData.HyperliquidBuilderApproved != nil {
effectiveHyperliquidBuilderApproved = *exchangeData.HyperliquidBuilderApproved
}
effectiveHyperliquidUnifiedAcct := effectiveHyperliquidUnifiedAccount(
existing.ExchangeType,
exchangeData.HyperliquidUnifiedAcct,
existing.HyperliquidUnifiedAcct,
)
if missing := store.MissingRequiredExchangeCredentialFields(
existing.ExchangeType,
effectiveAPIKey,
effectiveSecretKey,
effectivePassphrase,
effectiveHyperliquidWalletAddr,
effectiveAsterUser,
effectiveAsterSigner,
effectiveAsterPrivateKey,
effectiveLighterWalletAddr,
effectiveLighterAPIKeyPrivateKey,
); len(missing) > 0 {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Missing required exchange fields: %s", strings.Join(missing, ", ")),
"missing_fields": missing,
})
return
}
// Find traders using this exchange BEFORE updating
traders, _ := s.store.Trader().ListByExchangeID(userID, exchangeID)
for _, t := range traders {
tradersToReload[t.ID] = true
}
err := s.store.Exchange().Update(userID, exchangeID, exchangeData.Enabled, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Passphrase, exchangeData.Testnet, exchangeData.HyperliquidWalletAddr, exchangeData.HyperliquidUnifiedAcct, exchangeData.AsterUser, exchangeData.AsterSigner, exchangeData.AsterPrivateKey, exchangeData.LighterWalletAddr, exchangeData.LighterPrivateKey, exchangeData.LighterAPIKeyPrivateKey, exchangeData.LighterAPIKeyIndex)
err = s.store.Exchange().Update(userID, exchangeID, true, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Passphrase, exchangeData.Testnet, effectiveHyperliquidWalletAddr, effectiveHyperliquidUnifiedAcct, effectiveHyperliquidBuilderApproved, effectiveAsterUser, effectiveAsterSigner, exchangeData.AsterPrivateKey, effectiveLighterWalletAddr, exchangeData.LighterPrivateKey, exchangeData.LighterAPIKeyPrivateKey, exchangeData.LighterAPIKeyIndex)
if err != nil {
SafeInternalError(c, fmt.Sprintf("Update exchange %s", exchangeID), err)
return
@@ -207,7 +323,7 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
// Don't return error here since exchange config was successfully updated to database
}
logger.Infof("✓ Exchange config updated: %+v", req.Exchanges)
logger.Infof("✓ Exchange config updated: %+v", SanitizeExchangeConfigForLog(req.Exchanges))
c.JSON(http.StatusOK, gin.H{"message": "Exchange configuration updated"})
}
@@ -271,12 +387,31 @@ func (s *Server) handleCreateExchange(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid exchange type: %s", req.ExchangeType)})
return
}
if missing := store.MissingRequiredExchangeCredentialFields(
req.ExchangeType,
req.APIKey,
req.SecretKey,
req.Passphrase,
req.HyperliquidWalletAddr,
req.AsterUser,
req.AsterSigner,
req.AsterPrivateKey,
req.LighterWalletAddr,
req.LighterAPIKeyPrivateKey,
); len(missing) > 0 {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Missing required exchange fields: %s", strings.Join(missing, ", ")),
"missing_fields": missing,
})
return
}
// Create new exchange account
// Exchange configs only persist once complete; persisted configs are always enabled.
effectiveHyperliquidUnifiedAcct := effectiveHyperliquidUnifiedAccount(req.ExchangeType, req.HyperliquidUnifiedAcct)
id, err := s.store.Exchange().Create(
userID, req.ExchangeType, req.AccountName, req.Enabled,
userID, req.ExchangeType, req.AccountName, true,
req.APIKey, req.SecretKey, req.Passphrase, req.Testnet,
req.HyperliquidWalletAddr, req.HyperliquidUnifiedAcct,
req.HyperliquidWalletAddr, effectiveHyperliquidUnifiedAcct, req.HyperliquidBuilderApproved,
req.AsterUser, req.AsterSigner, req.AsterPrivateKey,
req.LighterWalletAddr, req.LighterPrivateKey, req.LighterAPIKeyPrivateKey, req.LighterAPIKeyIndex,
)

View File

@@ -0,0 +1,66 @@
package api
import (
"testing"
"nofx/crypto"
"nofx/store"
)
func TestSafeExchangeConfigFromStoreIncludesCredentialPresenceFlags(t *testing.T) {
cfg := &store.Exchange{
ID: "ex-1",
ExchangeType: "okx",
AccountName: "OKX Main",
Name: "OKX Main",
Type: "cex",
Enabled: true,
APIKey: crypto.EncryptedString("api-test-123"),
SecretKey: crypto.EncryptedString("secret-test-123"),
Passphrase: crypto.EncryptedString("passphrase-test-123"),
HyperliquidUnifiedAcct: true,
AsterPrivateKey: crypto.EncryptedString("aster-private-key"),
LighterPrivateKey: crypto.EncryptedString("lighter-private-key"),
LighterAPIKeyPrivateKey: crypto.EncryptedString("lighter-api-key-private-key"),
}
safe := safeExchangeConfigFromStore(cfg)
if !safe.HasAPIKey {
t.Fatalf("expected has_api_key to be true")
}
if !safe.HasSecretKey {
t.Fatalf("expected has_secret_key to be true")
}
if !safe.HasPassphrase {
t.Fatalf("expected has_passphrase to be true")
}
if !safe.HasAsterPrivateKey {
t.Fatalf("expected has_aster_private_key to be true")
}
if !safe.HasLighterPrivateKey {
t.Fatalf("expected has_lighter_private_key to be true")
}
if !safe.HasLighterAPIKey {
t.Fatalf("expected has_lighter_api_key_private_key to be true")
}
if !safe.HyperliquidUnifiedAcct {
t.Fatalf("expected hyperliquid unified account to be exposed")
}
}
func TestEffectiveHyperliquidUnifiedAccountDefaultsAndPreserves(t *testing.T) {
if !effectiveHyperliquidUnifiedAccount("hyperliquid", nil) {
t.Fatalf("expected new hyperliquid accounts to default unified account on")
}
if effectiveHyperliquidUnifiedAccount("binance", nil) {
t.Fatalf("expected non-hyperliquid accounts to default unified account off")
}
fallbackFalse := effectiveHyperliquidUnifiedAccount("hyperliquid", nil, false)
if fallbackFalse {
t.Fatalf("expected omitted update field to preserve existing false value")
}
requestedTrue := true
if !effectiveHyperliquidUnifiedAccount("hyperliquid", &requestedTrue, false) {
t.Fatalf("expected explicit true to override existing false value")
}
}

View File

@@ -0,0 +1,413 @@
package api
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
const (
defaultHyperliquidBuilderAddress = "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d"
// 0.05% (5 bps) — matches BuilderInfo.Fee=50 charged at order placement.
// New wallet approvals sign this exact value; existing approvals at the
// prior 0.1% cap remain valid because 0.05% is within their approved max.
defaultHyperliquidBuilderMaxFee = "0.05%"
hyperliquidExchangeURL = "https://api.hyperliquid.xyz/exchange"
hyperliquidInfoURL = "https://api.hyperliquid.xyz/info"
// nofxHyperliquidAgentName must match AGENT_NAME used by the frontend
// approveAgent flow so we can locate the NOFX-managed agent on-chain.
nofxHyperliquidAgentName = "NOFX Agent"
)
type hyperliquidSubmitRequest struct {
Action map[string]any `json:"action" binding:"required"`
Nonce int64 `json:"nonce" binding:"required"`
Signature struct {
R string `json:"r" binding:"required"`
S string `json:"s" binding:"required"`
V int `json:"v"`
} `json:"signature" binding:"required"`
}
type hyperliquidConfigResponse struct {
BuilderAddress string `json:"builderAddress"`
BuilderMaxFee string `json:"builderMaxFee"`
Chain string `json:"chain"`
SignatureChain string `json:"signatureChainId"`
}
type hyperliquidAccountSummary struct {
Address string `json:"address"`
AccountValue float64 `json:"accountValue"`
Withdrawable float64 `json:"withdrawable"`
TotalMarginUsed float64 `json:"totalMarginUsed"`
UnrealizedPnl float64 `json:"unrealizedPnl"`
OpenPositions int `json:"openPositions"`
UpdatedAt int64 `json:"updatedAt"`
}
type hyperliquidAgentInfo struct {
Name string `json:"name"`
Address string `json:"address"`
ValidUntil int64 `json:"validUntil"` // unix milliseconds
}
type hyperliquidAgentResponse struct {
// Agent is the NOFX-managed agent ("NOFX Agent"), nil when none is approved.
Agent *hyperliquidAgentInfo `json:"agent"`
// Agents lists every approved agent for the wallet (for visibility/cleanup).
Agents []hyperliquidAgentInfo `json:"agents"`
}
type hyperliquidClearinghouseState struct {
MarginSummary struct {
AccountValue string `json:"accountValue"`
TotalMarginUsed string `json:"totalMarginUsed"`
} `json:"marginSummary"`
CrossMarginSummary struct {
AccountValue string `json:"accountValue"`
TotalMarginUsed string `json:"totalMarginUsed"`
} `json:"crossMarginSummary"`
Withdrawable string `json:"withdrawable"`
AssetPositions []struct {
Position struct {
Szi string `json:"szi"`
UnrealizedPnl string `json:"unrealizedPnl"`
} `json:"position"`
} `json:"assetPositions"`
}
// agentValidUntilSuffix matches the " valid_until <ms>" suffix Hyperliquid uses
// to encode an agent's expiry inside the agent name. Hyperliquid normally strips
// it from the stored name, but we strip defensively before matching the slot.
var agentValidUntilSuffix = regexp.MustCompile(` valid_until \d+$`)
func baseAgentName(name string) string {
return strings.TrimSpace(agentValidUntilSuffix.ReplaceAllString(name, ""))
}
func hyperliquidBuilderAddress() string {
return defaultHyperliquidBuilderAddress
}
func hyperliquidBuilderMaxFee() string {
return defaultHyperliquidBuilderMaxFee
}
func (s *Server) handleHyperliquidConnectConfig(c *gin.Context) {
c.JSON(http.StatusOK, hyperliquidConfigResponse{
BuilderAddress: hyperliquidBuilderAddress(),
BuilderMaxFee: hyperliquidBuilderMaxFee(),
Chain: "Mainnet",
SignatureChain: "0x66eee",
})
}
func (s *Server) handleHyperliquidAccount(c *gin.Context) {
address := strings.ToLower(strings.TrimSpace(c.Query("address")))
if !isEVMAddress(address) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid Hyperliquid wallet address"})
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
if err := json.Unmarshal(respBody, &state); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to parse Hyperliquid balance response"})
return
}
accountValue := parseFloatOrZero(state.MarginSummary.AccountValue)
if accountValue == 0 {
accountValue = parseFloatOrZero(state.CrossMarginSummary.AccountValue)
}
marginUsed := parseFloatOrZero(state.MarginSummary.TotalMarginUsed)
if marginUsed == 0 {
marginUsed = parseFloatOrZero(state.CrossMarginSummary.TotalMarginUsed)
}
var unrealizedPnl float64
openPositions := 0
for _, position := range state.AssetPositions {
size := parseFloatOrZero(position.Position.Szi)
if size != 0 {
openPositions++
}
unrealizedPnl += parseFloatOrZero(position.Position.UnrealizedPnl)
}
c.JSON(http.StatusOK, hyperliquidAccountSummary{
Address: address,
AccountValue: accountValue,
Withdrawable: parseFloatOrZero(state.Withdrawable),
TotalMarginUsed: marginUsed,
UnrealizedPnl: unrealizedPnl,
OpenPositions: openPositions,
UpdatedAt: time.Now().UnixMilli(),
})
}
// 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
// warn before the 180-day authorization lapses.
func (s *Server) handleHyperliquidAgent(c *gin.Context) {
address := strings.ToLower(strings.TrimSpace(c.Query("address")))
if !isEVMAddress(address) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid Hyperliquid wallet address"})
return
}
body, err := json.Marshal(map[string]any{"type": "extraAgents", "user": address})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode Hyperliquid agent 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 agent 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 agent request", "status": resp.StatusCode})
return
}
// extraAgents returns null when no agents are approved.
agents := []hyperliquidAgentInfo{}
if len(respBody) > 0 && string(bytes.TrimSpace(respBody)) != "null" {
if err := json.Unmarshal(respBody, &agents); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to parse Hyperliquid agent response"})
return
}
}
out := hyperliquidAgentResponse{Agents: agents}
for i := range agents {
if strings.EqualFold(baseAgentName(agents[i].Name), nofxHyperliquidAgentName) {
agent := agents[i]
out.Agent = &agent
break
}
}
c.JSON(http.StatusOK, out)
}
func (s *Server) handleHyperliquidSubmitExchange(c *gin.Context) {
var req hyperliquidSubmitRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid Hyperliquid submit payload"})
return
}
if err := validateSubmittedNonce(req.Action, req.Nonce); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
actionType, _ := req.Action["type"].(string)
switch actionType {
case "approveAgent":
if err := validateApproveAgentAction(req.Action); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
case "approveBuilderFee":
if err := validateApproveBuilderFeeAction(req.Action); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported Hyperliquid action"})
return
}
payload := map[string]any{
"action": req.Action,
"nonce": req.Nonce,
"signature": req.Signature,
}
body, err := json.Marshal(payload)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode Hyperliquid payload"})
return
}
client := &http.Client{Timeout: 20 * time.Second}
hlReq, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, hyperliquidExchangeURL, bytes.NewReader(body))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create Hyperliquid request"})
return
}
hlReq.Header.Set("Content-Type", "application/json")
resp, err := client.Do(hlReq)
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))
var decoded any
if len(respBody) > 0 {
_ = json.Unmarshal(respBody, &decoded)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
c.JSON(http.StatusBadGateway, gin.H{"error": "Hyperliquid rejected the action", "status": resp.StatusCode, "response": decoded})
return
}
// Hyperliquid returns HTTP 200 even for logical failures, signalling them via
// {"status":"err","response":"<message>"}. Without this check a rejected
// approval (e.g. valid_until past the cap, or an unchanged agent) is reported
// to the user as success while nothing changes on-chain.
var hlResp struct {
Status string `json:"status"`
Response json.RawMessage `json:"response"`
}
if err := json.Unmarshal(respBody, &hlResp); err == nil && strings.EqualFold(hlResp.Status, "err") {
msg := strings.TrimSpace(strings.Trim(string(hlResp.Response), `"`))
if msg == "" {
msg = "Hyperliquid rejected the action"
}
c.JSON(http.StatusBadGateway, gin.H{"error": msg, "response": decoded})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "response": decoded})
}
func validateApproveAgentAction(action map[string]any) error {
if strings.TrimSpace(fmt.Sprint(action["agentAddress"])) == "" {
return fmt.Errorf("missing agentAddress")
}
if strings.TrimSpace(fmt.Sprint(action["agentName"])) == "" {
return fmt.Errorf("missing agentName")
}
return validateCommonHyperliquidSignedAction(action)
}
func validateApproveBuilderFeeAction(action map[string]any) error {
builder := strings.ToLower(strings.TrimSpace(fmt.Sprint(action["builder"])))
if builder != hyperliquidBuilderAddress() {
return fmt.Errorf("builder address mismatch")
}
if strings.TrimSpace(fmt.Sprint(action["maxFeeRate"])) != hyperliquidBuilderMaxFee() {
return fmt.Errorf("builder max fee mismatch")
}
return validateCommonHyperliquidSignedAction(action)
}
func validateCommonHyperliquidSignedAction(action map[string]any) error {
if strings.TrimSpace(fmt.Sprint(action["signatureChainId"])) != "0x66eee" {
return fmt.Errorf("invalid signatureChainId")
}
if strings.TrimSpace(fmt.Sprint(action["hyperliquidChain"])) != "Mainnet" {
return fmt.Errorf("invalid hyperliquidChain")
}
if _, err := actionNonce(action); err != nil {
return err
}
return nil
}
func validateSubmittedNonce(action map[string]any, submitted int64) error {
actionValue, err := actionNonce(action)
if err != nil {
return err
}
if actionValue != submitted {
return fmt.Errorf("nonce mismatch")
}
return nil
}
func isEVMAddress(address string) bool {
if len(address) != 42 || !strings.HasPrefix(address, "0x") {
return false
}
for _, char := range address[2:] {
if (char < '0' || char > '9') && (char < 'a' || char > 'f') {
return false
}
}
return true
}
func parseFloatOrZero(value string) float64 {
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
if err != nil {
return 0
}
return parsed
}
func actionNonce(action map[string]any) (int64, error) {
raw, ok := action["nonce"]
if !ok {
return 0, fmt.Errorf("missing nonce")
}
switch value := raw.(type) {
case float64:
return int64(value), nil
case int64:
return value, nil
case json.Number:
return value.Int64()
case string:
return strconv.ParseInt(value, 10, 64)
default:
return 0, fmt.Errorf("invalid nonce")
}
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"time"
@@ -320,61 +321,207 @@ func (s *Server) getKlinesFromHyperliquid(symbol, interval string, limit int) ([
return klines, nil
}
func hyperliquidXYZDisplayBase(baseSymbol string) string {
baseSymbol = strings.ToUpper(strings.TrimSpace(baseSymbol))
// User-facing names should be product names, not exchange shorthand tickers.
// Keep the internal symbol separate because Hyperliquid's xyz dex still routes
// orders/candles by the short coin name (for example xyz:SMSN).
fullNames := map[string]string{
"XYZ100": "XYZ100",
"TSLA": "TESLA",
"NVDA": "NVIDIA",
"GOLD": "GOLD",
"HOOD": "ROBINHOOD",
"INTC": "INTEL",
"PLTR": "PALANTIR",
"COIN": "COINBASE",
"META": "META",
"AAPL": "APPLE",
"MSFT": "MICROSOFT",
"ORCL": "ORACLE",
"GOOGL": "GOOGLE",
"AMZN": "AMAZON",
"AMD": "AMD",
"MU": "MICRON",
"SNDK": "SANDISK",
"MSTR": "MICROSTRATEGY",
"CRCL": "CIRCLE",
"NFLX": "NETFLIX",
"COST": "COSTCO",
"LLY": "ELI-LILLY",
"SKHX": "SK-HYNIX",
"TSM": "TSMC",
"JPY": "JPY",
"EUR": "EUR",
"SILVER": "SILVER",
"RIVN": "RIVIAN",
"BABA": "ALIBABA",
"CL": "CRUDE-OIL",
"COPPER": "COPPER",
"NATGAS": "NATURAL-GAS",
"URANIUM": "URANIUM",
"ALUMINIUM": "ALUMINIUM",
"SMSN": "SAMSUNG",
"PLATINUM": "PLATINUM",
"USAR": "USA-RARE-EARTH",
"CRWV": "COREWEAVE",
"URNM": "URNM",
"PALLADIUM": "PALLADIUM",
"DXY": "DOLLAR-INDEX",
"GME": "GAMESTOP",
"KR200": "KOREA-200",
"SOFTBANK": "SOFTBANK",
"JP225": "JAPAN-225",
"HYUNDAI": "HYUNDAI",
"KIOXIA": "KIOXIA",
"EWY": "SOUTH-KOREA-ETF",
"EWJ": "JAPAN-ETF",
"BRENTOIL": "BRENT-OIL",
"VIX": "VIX",
"HIMS": "HIMS-HERS",
"SP500": "S&P-500",
"DKNG": "DRAFTKINGS",
"LITE": "LITECOIN",
"CORN": "CORN",
"XLE": "ENERGY-SECTOR-ETF",
"WHEAT": "WHEAT",
"TTF": "TTF-GAS",
"BX": "BLACKSTONE",
"PURRDAT": "PURRDAT",
"MRVL": "MARVELL",
"RKLB": "ROCKET-LAB",
"BIRD": "BIRD",
"VOL": "VOLATILITY",
"DRAM": "DRAM",
"CBRS": "COINBASE-PRE-IPO",
"EWZ": "BRAZIL-ETF",
"KRW": "KRW",
"ZM": "ZOOM",
"EBAY": "EBAY",
"H100": "H100",
"NIFTY": "NIFTY-50",
"ARM": "ARM",
"EWT": "TAIWAN-ETF",
"GBP": "GBP",
"SPCX": "SPACEX-PRE-IPO",
"IBOV": "IBOVESPA",
"ASML": "ASML",
}
if fullName, ok := fullNames[baseSymbol]; ok {
return fullName
}
return baseSymbol
}
func hyperliquidXYZCategory(baseSymbol string) string {
baseSymbol = strings.ToUpper(strings.TrimSpace(baseSymbol))
switch baseSymbol {
case "GOLD", "SILVER", "CL", "COPPER", "NATGAS", "URANIUM", "ALUMINIUM", "PLATINUM", "PALLADIUM", "BRENTOIL", "CORN", "WHEAT", "TTF":
return "commodity"
case "XYZ100", "SP500", "JP225", "KR200", "DXY", "VIX", "XLE", "EWY", "EWJ", "EWZ", "EWT", "NIFTY", "IBOV":
return "index"
case "EUR", "JPY", "GBP", "KRW":
return "forex"
case "SPCX", "BIRD", "PURRDAT", "H100", "CBRS":
return "pre_ipo"
default:
return "stock"
}
}
func hyperliquidCategoryOrder(category string) int {
switch category {
case "stock":
return 0
case "commodity":
return 1
case "index":
return 2
case "forex":
return 3
case "pre_ipo":
return 4
case "crypto":
return 5
default:
return 99
}
}
// handleSymbols returns available symbols for a given exchange
func (s *Server) handleSymbols(c *gin.Context) {
exchange := c.DefaultQuery("exchange", "hyperliquid")
type SymbolInfo struct {
Symbol string `json:"symbol"`
Display string `json:"display"`
Name string `json:"name"`
Category string `json:"category"` // crypto, stock, forex, commodity, index
Exchange string `json:"exchange"`
Volume24h float64 `json:"volume_24h"`
MarkPrice float64 `json:"mark_price"`
PrevDayPrice float64 `json:"prev_day_price,omitempty"`
Change24hPct float64 `json:"change_24h_pct,omitempty"`
MaxLeverage int `json:"maxLeverage,omitempty"`
SzDecimals int `json:"sz_decimals,omitempty"`
}
var symbols []SymbolInfo
switch strings.ToLower(exchange) {
exchangeLower := strings.ToLower(exchange)
switch exchangeLower {
case "hyperliquid", "hyperliquid-xyz", "xyz":
// Fetch symbols from Hyperliquid
client := hyperliquid.NewClient()
ctx := context.Background()
// Get crypto perps from default dex
if exchange == "hyperliquid" || exchange == "hyperliquid-xyz" {
mids, err := client.GetAllMids(ctx)
if err == nil {
for symbol := range mids {
// Skip spot tokens (start with @)
if strings.HasPrefix(symbol, "@") {
continue
// hyperliquid-xyz returns the full USDC trading board in product order:
// stocks → commodities → indices → forex → pre-IPO → crypto.
if exchangeLower == "hyperliquid-xyz" || exchangeLower == "xyz" {
xyzCoins, err := hyperliquid.GetPerpDexCoins(ctx, hyperliquid.XYZDex)
if err != nil {
SafeInternalError(c, "Get Hyperliquid XYZ symbols", err)
return
}
for _, coin := range xyzCoins {
baseSymbol := strings.TrimPrefix(coin.Symbol, "xyz:")
displayBase := hyperliquidXYZDisplayBase(baseSymbol)
displaySymbol := displayBase + "-USDC"
tradeSymbol := baseSymbol + "-USDC"
symbols = append(symbols, SymbolInfo{
Symbol: symbol,
Name: symbol,
Category: "crypto",
Symbol: tradeSymbol,
Display: displaySymbol,
Name: displayBase,
Category: hyperliquidXYZCategory(baseSymbol),
Exchange: "hyperliquid-xyz",
Volume24h: coin.Volume24h,
MarkPrice: coin.MarkPrice,
PrevDayPrice: coin.PrevDayPrice,
Change24hPct: coin.Change24hPct,
MaxLeverage: coin.MaxLeverage,
SzDecimals: coin.SzDecimals,
})
}
}
}
// Get xyz dex symbols (stocks, forex, commodities)
xyzMids, err := client.GetAllMidsXYZ(ctx)
if err == nil {
for symbol := range xyzMids {
// Remove xyz: prefix for display
displaySymbol := strings.TrimPrefix(symbol, "xyz:")
category := "stock"
if displaySymbol == "GOLD" || displaySymbol == "SILVER" {
category = "commodity"
} else if displaySymbol == "EUR" || displaySymbol == "JPY" {
category = "forex"
} else if displaySymbol == "XYZ100" {
category = "index"
// Crypto perps are shown last; only include them on the combined Hyperliquid board.
if exchangeLower == "hyperliquid" || exchangeLower == "hyperliquid-xyz" {
coins, err := hyperliquid.GetProvider().GetAllCoins(ctx)
if err != nil {
SafeInternalError(c, "Get Hyperliquid symbols", err)
return
}
for _, coin := range coins {
symbols = append(symbols, SymbolInfo{
Symbol: displaySymbol,
Name: displaySymbol,
Category: category,
Symbol: coin.Symbol,
Display: coin.Symbol,
Name: coin.Symbol,
Category: "crypto",
Exchange: "hyperliquid",
Volume24h: coin.Volume24h,
MarkPrice: coin.MarkPrice,
PrevDayPrice: coin.PrevDayPrice,
Change24hPct: coin.Change24hPct,
MaxLeverage: coin.MaxLeverage,
SzDecimals: coin.SzDecimals,
})
}
}
@@ -384,6 +531,15 @@ func (s *Server) handleSymbols(c *gin.Context) {
return
}
sort.SliceStable(symbols, func(i, j int) bool {
ci := hyperliquidCategoryOrder(symbols[i].Category)
cj := hyperliquidCategoryOrder(symbols[j].Category)
if ci != cj {
return ci < cj
}
return symbols[i].Volume24h > symbols[j].Volume24h
})
c.JSON(http.StatusOK, gin.H{
"exchange": exchange,
"symbols": symbols,

View File

@@ -26,6 +26,7 @@ type beginnerOnboardingResponse struct {
DefaultModel string `json:"default_model"`
ConfiguredModelID string `json:"configured_model_id"`
BalanceUSDC string `json:"balance_usdc"`
BalanceStatus string `json:"balance_status,omitempty"`
EnvSaved bool `json:"env_saved"`
EnvPath string `json:"env_path,omitempty"`
ReusedExisting bool `json:"reused_existing"`
@@ -36,10 +37,22 @@ type currentBeginnerWalletResponse struct {
Found bool `json:"found"`
Address string `json:"address,omitempty"`
BalanceUSDC string `json:"balance_usdc,omitempty"`
BalanceStatus string `json:"balance_status,omitempty"`
Source string `json:"source,omitempty"`
Claw402Status string `json:"claw402_status"`
}
// queryBeginnerWalletBalance returns the wallet balance plus a status flag so
// the UI can distinguish "RPC unreachable" from a genuinely empty wallet.
// Uses the 30s cache — safe for UI polling.
func queryBeginnerWalletBalance(address string) (balanceUSDC string, balanceStatus string) {
balance, err := wallet.QueryUSDCBalanceCached(address)
if err != nil {
return "", "unknown"
}
return fmt.Sprintf("%.2f", balance), "ok"
}
func (s *Server) handleBeginnerOnboarding(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
@@ -72,6 +85,7 @@ func (s *Server) handleBeginnerOnboarding(c *gin.Context) {
os.Setenv("CLAW402_DEFAULT_MODEL", payment.DefaultClaw402Model)
envSaved, envPath, envErr := persistBeginnerWalletEnv(privateKey, address)
balanceUSDC, balanceStatus := queryBeginnerWalletBalance(address)
resp := beginnerOnboardingResponse{
Address: address,
PrivateKey: privateKey,
@@ -80,7 +94,8 @@ func (s *Server) handleBeginnerOnboarding(c *gin.Context) {
Provider: "claw402",
DefaultModel: payment.DefaultClaw402Model,
ConfiguredModelID: configuredModelID,
BalanceUSDC: wallet.QueryUSDCBalanceStr(address),
BalanceUSDC: balanceUSDC,
BalanceStatus: balanceStatus,
EnvSaved: envSaved,
EnvPath: envPath,
ReusedExisting: reusedExisting,
@@ -124,10 +139,12 @@ func (s *Server) handleCurrentBeginnerWallet(c *gin.Context) {
continue
}
balanceUSDC, balanceStatus := queryBeginnerWalletBalance(address)
c.JSON(http.StatusOK, currentBeginnerWalletResponse{
Found: true,
Address: address,
BalanceUSDC: wallet.QueryUSDCBalanceStr(address),
BalanceUSDC: balanceUSDC,
BalanceStatus: balanceStatus,
Source: "model",
Claw402Status: claw402Status,
})
@@ -136,10 +153,12 @@ func (s *Server) handleCurrentBeginnerWallet(c *gin.Context) {
address := strings.TrimSpace(os.Getenv("CLAW402_WALLET_ADDRESS"))
if address != "" {
balanceUSDC, balanceStatus := queryBeginnerWalletBalance(address)
c.JSON(http.StatusOK, currentBeginnerWalletResponse{
Found: true,
Address: address,
BalanceUSDC: wallet.QueryUSDCBalanceStr(address),
BalanceUSDC: balanceUSDC,
BalanceStatus: balanceStatus,
Source: "env",
Claw402Status: claw402Status,
})

View File

@@ -3,6 +3,7 @@ package api
import (
"net/http"
"strconv"
"strings"
"nofx/logger"
"nofx/market"
@@ -206,21 +207,43 @@ func (s *Server) handlePositionHistory(c *gin.Context) {
return
}
userID := c.GetString("user_id")
if fullCfg, cfgErr := s.store.Trader().GetFullConfig(userID, traderID); cfgErr == nil && fullCfg.Exchange != nil {
if syncErr := s.syncOrdersFromExchange(
trader.GetUnderlyingTrader(),
trader.GetID(),
fullCfg.Exchange.ID,
fullCfg.Exchange.ExchangeType,
); syncErr != nil {
logger.Infof("⚠️ Position history refresh sync skipped: %v", syncErr)
}
}
traderIDs := []string{trader.GetID()}
var traderIDPatterns []string
if strings.EqualFold(strings.TrimSpace(trader.GetName()), "NOFX Autopilot") && strings.TrimSpace(userID) != "" {
// Older one-click launches created new Autopilot trader rows. When a row was
// deleted, its closed position records remained under the old generated ID.
// The generated Autopilot ID embeds userID + "claw402", so this safely
// restores same-user history continuity without joining deleted rows.
traderIDPatterns = append(traderIDPatterns, "%_"+userID+"_claw402_%")
}
// Get closed positions
positions, err := store.Position().GetClosedPositions(trader.GetID(), limit)
positions, err := store.Position().GetClosedPositionsByTraderFilters(traderIDs, traderIDPatterns, limit)
if err != nil {
SafeInternalError(c, "Get position history", err)
return
}
// Get statistics
stats, _ := store.Position().GetFullStats(trader.GetID())
stats, _ := store.Position().GetFullStatsByTraderFilters(traderIDs, traderIDPatterns, trader.GetInitialBalance())
// Get symbol stats
symbolStats, _ := store.Position().GetSymbolStats(trader.GetID(), 10)
symbolStats, _ := store.Position().GetSymbolStatsByTraderFilters(traderIDs, traderIDPatterns, 10)
// Get direction stats
directionStats, _ := store.Position().GetDirectionStats(trader.GetID())
directionStats, _ := store.Position().GetDirectionStatsByTraderFilters(traderIDs, traderIDPatterns)
c.JSON(http.StatusOK, gin.H{
"positions": positions,
@@ -357,8 +380,8 @@ func (s *Server) handleOrderFills(c *gin.Context) {
return
}
// Get fills for this order
fills, err := store.Order().GetOrderFills(orderID)
// Get fills for this order, scoped to the trader (ownership boundary).
fills, err := store.Order().GetOrderFills(traderID, orderID)
if err != nil {
SafeInternalError(c, "Get order fills", err)
return

58
api/handler_stats_full.go Normal file
View File

@@ -0,0 +1,58 @@
package api
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// handleStatisticsFull returns the full set of computed performance metrics for
// a single trader: win rate, profit factor, Sharpe ratio, max drawdown, and the
// average win/loss amounts. These are derived from the trader's CLOSED positions
// via store.Position().GetFullStatsByTraderFilters — the same computation the
// strategy engine feeds to the AI, so the dashboard and the model see identical
// numbers.
//
// The existing GET /statistics endpoint only returns cycle/position counts; this
// endpoint exposes the richer trade-quality metrics the terminal dashboard needs.
func (s *Server) handleStatisticsFull(c *gin.Context) {
_, traderID, err := s.getTraderFromQuery(c)
if err != nil {
SafeBadRequest(c, "Invalid trader ID")
return
}
trader, err := s.traderManager.GetTrader(traderID)
if err != nil {
SafeNotFound(c, "Trader")
return
}
store := trader.GetStore()
if store == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Store not available"})
return
}
// Aggregate across the trader's historical IDs exactly like the position
// history endpoint (handler_order.go). One-click "NOFX Autopilot" relaunches
// create fresh trader rows, but the closed positions stay under the old
// generated IDs (which embed userID + "claw402"). Without this, a freshly
// relaunched Autopilot would report only the current incarnation's trades
// instead of its real lifetime history.
userID := c.GetString("user_id")
traderIDs := []string{trader.GetID()}
var traderIDPatterns []string
if strings.EqualFold(strings.TrimSpace(trader.GetName()), "NOFX Autopilot") && strings.TrimSpace(userID) != "" {
traderIDPatterns = append(traderIDPatterns, "%_"+userID+"_claw402_%")
}
stats, err := store.Position().GetFullStatsByTraderFilters(traderIDs, traderIDPatterns, trader.GetInitialBalance())
if err != nil {
SafeInternalError(c, "Get full statistics", err)
return
}
c.JSON(http.StatusOK, stats)
}

View File

@@ -14,6 +14,11 @@ import (
"gorm.io/gorm"
)
const (
maxManualBTCETHLeverage = 20
maxManualAltLeverage = 20
)
// AI trader management related structures
type CreateTraderRequest struct {
Name string `json:"name" binding:"required"`
@@ -56,26 +61,44 @@ type UpdateTraderRequest struct {
func formatTraderCreationError(reason, nextStep string) string {
if nextStep == "" {
return fmt.Sprintf("这次未能创建机器人:%s", reason)
return fmt.Sprintf("Failed to create the bot this time: %s.", reason)
}
return fmt.Sprintf("这次未能创建机器人:%s。%s", reason, nextStep)
return fmt.Sprintf("Failed to create the bot this time: %s. %s.", reason, nextStep)
}
func traderCreationRequestError(reason string) string {
return formatTraderCreationError(reason, "请检查你刚刚填写的内容后,再重新提交")
return formatTraderCreationError(reason, "Please check the information you just entered and submit again")
}
func validateTraderLeverageRange(btcEthLeverage, altcoinLeverage int) (string, string) {
if btcEthLeverage < 0 || btcEthLeverage > maxManualBTCETHLeverage {
return traderCreationRequestError("BTC/ETH leverage must be between 1x and 20x"), "trader.create.invalid_btc_eth_leverage"
}
if altcoinLeverage < 0 || altcoinLeverage > maxManualAltLeverage {
return traderCreationRequestError("Altcoin leverage must be between 1x and 20x"), "trader.create.invalid_altcoin_leverage"
}
return "", ""
}
func isSupportedTraderSymbol(symbol string) bool {
normalized := strings.ToUpper(strings.TrimSpace(symbol))
if normalized == "" {
return true
}
return strings.HasSuffix(normalized, "USDT") || strings.HasSuffix(normalized, "-USDC") || strings.HasPrefix(normalized, "XYZ:")
}
func exchangeDisplayName(exchange *store.Exchange) string {
if exchange == nil {
return "所选交易所账户"
return "the selected exchange account"
}
if exchange.AccountName != "" {
return fmt.Sprintf("%s%s", exchange.Name, exchange.AccountName)
return fmt.Sprintf("%s (%s)", exchange.Name, exchange.AccountName)
}
if exchange.Name != "" {
return exchange.Name
}
return "所选交易所账户"
return "the selected exchange account"
}
func missingExchangeFields(exchange *store.Exchange) []string {
@@ -104,10 +127,10 @@ func missingExchangeFields(exchange *store.Exchange) []string {
}
case "hyperliquid":
if exchange.APIKey == "" {
missing = append(missing, "私钥")
missing = append(missing, "Private Key")
}
if strings.TrimSpace(exchange.HyperliquidWalletAddr) == "" {
missing = append(missing, "钱包地址")
missing = append(missing, "Wallet Address")
}
case "aster":
if strings.TrimSpace(exchange.AsterUser) == "" {
@@ -121,7 +144,7 @@ func missingExchangeFields(exchange *store.Exchange) []string {
}
case "lighter":
if strings.TrimSpace(exchange.LighterWalletAddr) == "" {
missing = append(missing, "钱包地址")
missing = append(missing, "Wallet Address")
}
if exchange.LighterAPIKeyPrivateKey == "" {
missing = append(missing, "API Key Private Key")
@@ -145,21 +168,21 @@ func mapStringPairs(kv ...string) map[string]string {
func validateExchangeForTraderCreation(exchange *store.Exchange) (string, string, map[string]string) {
if exchange == nil {
return formatTraderCreationError("还没有找到你选择的交易所账户", "请前往「设置 > 交易所配置」先添加一个可用账户,再回来创建机器人"),
return formatTraderCreationError("The exchange account you selected was not found", "Please go to \"Settings > Exchange Config\" and add an available account first, then come back to create the bot"),
"trader.create.exchange_not_found", nil
}
if !exchange.Enabled {
return formatTraderCreationError(
fmt.Sprintf("交易所账户「%s」目前处于未启用状态", exchangeDisplayName(exchange)),
"请前往「设置 > 交易所配置」启用该账户后,再重新创建机器人",
fmt.Sprintf("Exchange account \"%s\" is currently disabled", exchangeDisplayName(exchange)),
"Please go to \"Settings > Exchange Config\" to enable this account, then create the bot again",
), "trader.create.exchange_disabled", mapStringPairs("exchange_name", exchangeDisplayName(exchange))
}
missing := missingExchangeFields(exchange)
if len(missing) > 0 {
return formatTraderCreationError(
fmt.Sprintf("交易所账户「%s」的配置还不完整缺少 %s", exchangeDisplayName(exchange), strings.Join(missing, "")),
"请前往「设置 > 交易所配置」补全该账户的必填信息后,再重新创建机器人",
fmt.Sprintf("The configuration for exchange account \"%s\" is incomplete, missing %s", exchangeDisplayName(exchange), strings.Join(missing, ", ")),
"Please go to \"Settings > Exchange Config\" to complete the required information for this account, then create the bot again",
), "trader.create.exchange_missing_fields", mapStringPairs(
"exchange_name", exchangeDisplayName(exchange),
"missing_fields", strings.Join(missing, ", "),
@@ -171,8 +194,8 @@ func validateExchangeForTraderCreation(exchange *store.Exchange) (string, string
return "", "", nil
default:
return formatTraderCreationError(
fmt.Sprintf("交易所账户「%s」使用了当前版本暂不支持的类型 %s", exchangeDisplayName(exchange), exchange.ExchangeType),
"请改用当前版本支持的交易所账户后,再重新创建机器人",
fmt.Sprintf("Exchange account \"%s\" uses type %s, which is not supported in the current version", exchangeDisplayName(exchange), exchange.ExchangeType),
"Please switch to an exchange account supported by the current version, then create the bot again",
), "trader.create.exchange_unsupported", mapStringPairs(
"exchange_name", exchangeDisplayName(exchange),
"exchange_type", exchange.ExchangeType,
@@ -191,28 +214,28 @@ func classifyTraderSetupReason(reason string) (string, string) {
switch {
case strings.Contains(lower, "failed to parse strategy config"),
strings.Contains(lower, "failed to parse strategy configuration"):
return "trader.reason.strategy_config_invalid", "当前策略配置内容已损坏,系统暂时无法解析"
return "trader.reason.strategy_config_invalid", "The current strategy configuration is corrupted and the system cannot parse it for now"
case strings.Contains(lower, "has no strategy configured"):
return "trader.reason.strategy_missing", "当前机器人缺少有效的交易策略配置"
return "trader.reason.strategy_missing", "The current bot is missing a valid trading strategy configuration"
case strings.Contains(lower, "failed to parse private key"),
(strings.Contains(lower, "invalid hex character") && strings.Contains(lower, "private key")):
return "trader.reason.private_key_invalid", "私钥格式不正确,系统无法识别"
return "trader.reason.private_key_invalid", "The private key format is incorrect and the system cannot recognize it"
case strings.Contains(lower, "failed to initialize hyperliquid trader"):
return "trader.reason.hyperliquid_init_failed", "Hyperliquid 账户初始化失败,请确认私钥、主钱包地址和 Agent Wallet 配置是否正确"
return "trader.reason.hyperliquid_init_failed", "Hyperliquid account initialization failed; please confirm the private key, main wallet address, and Agent Wallet configuration are correct"
case strings.Contains(lower, "failed to initialize aster trader"):
return "trader.reason.aster_init_failed", "Aster 账户初始化失败,请确认 Aster UserSigner 和私钥是否正确"
return "trader.reason.aster_init_failed", "Aster account initialization failed; please confirm the Aster User, Signer, and private key are correct"
case strings.Contains(lower, "failed to get meta information"):
return "trader.reason.exchange_meta_unavailable", "系统暂时无法从交易所读取账户元信息"
return "trader.reason.exchange_meta_unavailable", "The system cannot read account meta information from the exchange for now"
case strings.Contains(lower, "security check failed") && strings.Contains(lower, "agent wallet balance too high"):
return "trader.reason.hyperliquid_agent_balance_too_high", "Hyperliquid Agent Wallet 余额过高,不符合当前安全要求"
return "trader.reason.hyperliquid_agent_balance_too_high", "The Hyperliquid Agent Wallet balance is too high and does not meet the current security requirements"
case strings.Contains(lower, "failed to initialize account"):
return "trader.reason.exchange_account_init_failed", "交易所账户初始化失败,请确认钱包地址和 API Key 是否匹配"
return "trader.reason.exchange_account_init_failed", "Exchange account initialization failed; please confirm the wallet address and API Key match"
case strings.Contains(lower, "unsupported trading platform"):
return "trader.reason.exchange_unsupported", "当前交易所类型暂不支持机器人初始化"
return "trader.reason.exchange_unsupported", "The current exchange type does not support bot initialization"
case strings.Contains(lower, "initial balance not set and unable to fetch balance from exchange"):
return "trader.reason.exchange_balance_unavailable", "系统暂时无法从交易所读取账户余额"
return "trader.reason.exchange_balance_unavailable", "The system cannot read the account balance from the exchange for now"
case strings.Contains(lower, "timeout"), strings.Contains(lower, "no such host"), strings.Contains(lower, "connection refused"):
return "trader.reason.exchange_service_unreachable", "系统暂时无法连接交易所服务"
return "trader.reason.exchange_service_unreachable", "The system cannot connect to the exchange service for now"
default:
return "trader.reason.unknown", trimmed
}
@@ -247,54 +270,54 @@ func traderSetupReasonParams(err error, fallback string, kv ...string) map[strin
func describeTraderLoadError(traderName string, err error) string {
if err == nil {
return formatTraderCreationError("机器人配置虽然保存了,但运行实例没有成功初始化", "请检查模型、策略和交易所配置是否完整,然后再试一次")
return formatTraderCreationError("The bot configuration was saved, but the runtime instance failed to initialize", "Please check that the model, strategy, and exchange configuration are complete, then try again")
}
reason := humanizeTraderSetupReason(SanitizeError(err, ""))
if reason == "" {
return formatTraderCreationError(
fmt.Sprintf("机器人「%s」在初始化运行实例时没有成功启动", traderName),
"请检查模型、策略和交易所配置是否完整,然后再试一次",
fmt.Sprintf("Bot \"%s\" failed to start when initializing its runtime instance", traderName),
"Please check that the model, strategy, and exchange configuration are complete, then try again",
)
}
return formatTraderCreationError(
fmt.Sprintf("机器人「%s」在初始化运行实例时没有成功启动原因是%s", traderName, reason),
"请检查模型、策略和交易所配置是否完整,然后再试一次",
fmt.Sprintf("Bot \"%s\" failed to start when initializing its runtime instance, because: %s", traderName, reason),
"Please check that the model, strategy, and exchange configuration are complete, then try again",
)
}
func describeTraderCreationWarning(traderName string, err error) string {
if err == nil {
return fmt.Sprintf("机器人「%s」已经保存但当前还没有通过启动前校验。请先检查模型、策略和交易所配置修正后再点击启动。", traderName)
return fmt.Sprintf("Bot \"%s\" has been saved, but it has not yet passed the pre-start validation. Please check the model, strategy, and exchange configuration first, then click start after fixing them.", traderName)
}
reason := humanizeTraderSetupReason(SanitizeError(err, ""))
if reason == "" {
return fmt.Sprintf("机器人「%s」已经保存但当前暂时还不能启动。请先检查模型、策略和交易所配置修正后再点击启动。", traderName)
return fmt.Sprintf("Bot \"%s\" has been saved, but it cannot start for now. Please check the model, strategy, and exchange configuration first, then click start after fixing them.", traderName)
}
return fmt.Sprintf("机器人「%s」已经保存但当前暂时还不能启动原因是%s。请先检查模型、策略和交易所配置修正后再点击启动。", traderName, reason)
return fmt.Sprintf("Bot \"%s\" has been saved, but it cannot start for now, because: %s. Please check the model, strategy, and exchange configuration first, then click start after fixing them.", traderName, reason)
}
func describeTraderStartError(traderName string, err error) string {
if err == nil {
return fmt.Sprintf("这次未能启动机器人:机器人「%s」暂时还不能启动。请检查模型、策略和交易所配置后再重新点击启动。", traderName)
return fmt.Sprintf("Failed to start the bot this time: bot \"%s\" cannot start for now. Please check the model, strategy, and exchange configuration, then click start again.", traderName)
}
reason := humanizeTraderSetupReason(SanitizeError(err, ""))
if reason == "" {
return fmt.Sprintf("这次未能启动机器人:机器人「%s」暂时还不能启动。请检查模型、策略和交易所配置后再重新点击启动。", traderName)
return fmt.Sprintf("Failed to start the bot this time: bot \"%s\" cannot start for now. Please check the model, strategy, and exchange configuration, then click start again.", traderName)
}
return fmt.Sprintf("这次未能启动机器人:机器人「%s」暂时还不能启动原因是%s。请检查模型、策略和交易所配置后再重新点击启动。", traderName, reason)
return fmt.Sprintf("Failed to start the bot this time: bot \"%s\" cannot start for now, because: %s. Please check the model, strategy, and exchange configuration, then click start again.", traderName, reason)
}
func formatTraderStartError(reason, nextStep string) string {
if nextStep == "" {
return fmt.Sprintf("这次未能启动机器人:%s", reason)
return fmt.Sprintf("Failed to start the bot this time: %s.", reason)
}
return fmt.Sprintf("这次未能启动机器人:%s。%s", reason, nextStep)
return fmt.Sprintf("Failed to start the bot this time: %s. %s.", reason, nextStep)
}
// handleCreateTrader Create new AI trader
@@ -302,28 +325,26 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
userID := c.GetString("user_id")
var req CreateTraderRequest
if err := c.ShouldBindJSON(&req); err != nil {
SafeBadRequestWithDetails(c, traderCreationRequestError("提交的信息不完整,或者格式不正确"), "trader.create.invalid_request", nil)
SafeBadRequestWithDetails(c, traderCreationRequestError("The submitted information is incomplete or has an invalid format"), "trader.create.invalid_request", nil)
return
}
// Validate leverage values
if req.BTCETHLeverage < 0 || req.BTCETHLeverage > 50 {
SafeBadRequestWithDetails(c, traderCreationRequestError("BTC/ETH 杠杆倍数需要在 1 到 50 倍之间"), "trader.create.invalid_btc_eth_leverage", nil)
return
}
if req.AltcoinLeverage < 0 || req.AltcoinLeverage > 20 {
SafeBadRequestWithDetails(c, traderCreationRequestError("山寨币杠杆倍数需要在 1 到 20 倍之间"), "trader.create.invalid_altcoin_leverage", nil)
// Validate leverage values against the same limits exposed by manual user config.
if errMsg, errCode := validateTraderLeverageRange(req.BTCETHLeverage, req.AltcoinLeverage); errMsg != "" {
SafeBadRequestWithDetails(c, errMsg, errCode, nil)
return
}
// Validate trading symbol format
// Validate trading symbol format. Hyperliquid xyz dex markets (stocks,
// commodities, indices, FX, Pre-IPO) are user-facing SYMBOL-USDC pairs,
// while standard crypto/perp markets keep the legacy USDT suffix format.
if req.TradingSymbols != "" {
symbols := strings.Split(req.TradingSymbols, ",")
for _, symbol := range symbols {
symbol = strings.TrimSpace(symbol)
if symbol != "" && !strings.HasSuffix(strings.ToUpper(symbol), "USDT") {
if !isSupportedTraderSymbol(symbol) {
SafeBadRequestWithDetails(c, traderCreationRequestError(
fmt.Sprintf("交易对 %s 的格式不正确,目前只支持以 USDT 结尾的合约交易对", symbol),
fmt.Sprintf("The trading pair %s has an invalid format; only USDT perpetuals or Hyperliquid XYZ USDC instruments (SYMBOL-USDC) are currently supported", symbol),
), "trader.create.invalid_symbol", mapStringPairs("symbol", symbol))
return
}
@@ -333,32 +354,32 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
model, err := s.store.AIModel().Get(userID, req.AIModelID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
SafeBadRequestWithDetails(c, formatTraderCreationError("还没有找到你选择的 AI 模型", "请前往「设置 > 模型配置」先添加并启用一个可用模型,再回来创建机器人"), "trader.create.model_not_found", nil)
SafeBadRequestWithDetails(c, formatTraderCreationError("The AI model you selected was not found", "Please go to \"Settings > Model Config\" to add and enable an available model first, then come back to create the bot"), "trader.create.model_not_found", nil)
return
}
SafeError(c, http.StatusInternalServerError,
formatTraderCreationError("暂时无法读取你的 AI 模型配置", "请稍后重试;如果问题持续,再检查本地服务是否正常"),
formatTraderCreationError("Unable to read your AI model configuration for now", "Please retry later; if the problem persists, check whether the local service is running normally"),
err,
)
return
}
if !model.Enabled {
SafeBadRequestWithDetails(c, formatTraderCreationError(
fmt.Sprintf("AI 模型「%s」目前还没有启用", model.Name),
"请前往「设置 > 模型配置」启用它后,再重新创建机器人",
fmt.Sprintf("AI model \"%s\" is not enabled yet", model.Name),
"Please go to \"Settings > Model Config\" to enable it, then create the bot again",
), "trader.create.model_disabled", mapStringPairs("model_name", model.Name))
return
}
if model.APIKey == "" {
SafeBadRequestWithDetails(c, formatTraderCreationError(
fmt.Sprintf("AI 模型「%s」缺少 API Key 或支付凭证", model.Name),
"请前往「设置 > 模型配置」补全模型凭证后,再重新创建机器人",
fmt.Sprintf("AI model \"%s\" is missing an API Key or payment credentials", model.Name),
"Please go to \"Settings > Model Config\" to complete the model credentials, then create the bot again",
), "trader.create.model_missing_credentials", mapStringPairs("model_name", model.Name))
return
}
if req.StrategyID == "" {
SafeBadRequestWithDetails(c, formatTraderCreationError("你还没有选择交易策略", "请先选择一个策略,再继续创建机器人"), "trader.create.strategy_required", nil)
SafeBadRequestWithDetails(c, formatTraderCreationError("You have not selected a trading strategy yet", "Please select a strategy first, then continue creating the bot"), "trader.create.strategy_required", nil)
return
}
@@ -366,11 +387,11 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
_, err = s.store.Strategy().Get(userID, req.StrategyID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
SafeBadRequestWithDetails(c, formatTraderCreationError("你选择的策略不存在,或者已经被删除了", "请重新选择一个可用策略后,再继续创建机器人"), "trader.create.strategy_not_found", nil)
SafeBadRequestWithDetails(c, formatTraderCreationError("The strategy you selected does not exist or has been deleted", "Please select another available strategy, then continue creating the bot"), "trader.create.strategy_not_found", nil)
return
}
SafeError(c, http.StatusInternalServerError,
formatTraderCreationError("暂时无法读取你选择的策略配置", "请稍后重试;如果问题持续,再检查本地服务是否正常"),
formatTraderCreationError("Unable to read the strategy configuration you selected for now", "Please retry later; if the problem persists, check whether the local service is running normally"),
err,
)
return
@@ -413,8 +434,10 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
// Set scan interval default value
scanIntervalMinutes := req.ScanIntervalMinutes
if scanIntervalMinutes < 3 {
scanIntervalMinutes = 3 // Default 3 minutes, not allowed to be less than 3
if scanIntervalMinutes <= 0 {
scanIntervalMinutes = 15
} else if scanIntervalMinutes < 3 {
scanIntervalMinutes = 3 // Explicit values below 3 minutes are clamped to the minimum.
}
// Query exchange actual balance, override user input
@@ -422,7 +445,7 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
exchanges, err := s.store.Exchange().List(userID)
if err != nil {
SafeError(c, http.StatusInternalServerError,
formatTraderCreationError("暂时无法读取你的交易所配置", "请稍后重试;如果问题持续,再检查本地服务是否正常"),
formatTraderCreationError("Unable to read your exchange configuration for now", "Please retry later; if the problem persists, check whether the local service is running normally"),
err,
)
return
@@ -446,9 +469,9 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
tempTrader, createErr := buildExchangeProbeTrader(exchangeCfg, userID)
if createErr != nil {
SafeBadRequestWithDetails(c, formatTraderCreationError(
fmt.Sprintf("交易所账户「%s」没有通过初始化校验原因是%s", exchangeDisplayName(exchangeCfg), humanizeTraderSetupReason(SanitizeError(createErr, "配置校验未通过"))),
"请前往「设置 > 交易所配置」检查这个账户的密钥、地址和账户信息是否填写正确",
), "trader.create.exchange_probe_failed", traderSetupReasonParams(createErr, "配置校验未通过",
fmt.Sprintf("Exchange account \"%s\" did not pass initialization validation, because: %s", exchangeDisplayName(exchangeCfg), humanizeTraderSetupReason(SanitizeError(createErr, "Configuration validation failed"))),
"Please go to \"Settings > Exchange Config\" to check whether this account's keys, address, and account information are entered correctly",
), "trader.create.exchange_probe_failed", traderSetupReasonParams(createErr, "Configuration validation failed",
"exchange_name", exchangeDisplayName(exchangeCfg),
))
return
@@ -498,9 +521,9 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
err = s.store.Trader().Create(traderRecord)
if err != nil {
logger.Infof("❌ Failed to create trader: %v", err)
publicMsg := SanitizeError(err, formatTraderCreationError("机器人配置没有保存成功", "请检查名称、模型、策略和交易所配置后,再试一次"))
publicMsg := SanitizeError(err, formatTraderCreationError("The bot configuration was not saved successfully", "Please check the name, model, strategy, and exchange configuration, then try again"))
statusCode := http.StatusBadRequest
if publicMsg == formatTraderCreationError("机器人配置没有保存成功", "请检查名称、模型、策略和交易所配置后,再试一次") {
if publicMsg == formatTraderCreationError("The bot configuration was not saved successfully", "Please check the name, model, strategy, and exchange configuration, then try again") {
statusCode = http.StatusInternalServerError
}
SafeError(c, statusCode, publicMsg, err)
@@ -574,6 +597,11 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
return
}
if errMsg, errCode := validateTraderLeverageRange(req.BTCETHLeverage, req.AltcoinLeverage); errMsg != "" {
SafeBadRequestWithDetails(c, errMsg, errCode, nil)
return
}
// Set default values
isCrossMargin := existingTrader.IsCrossMargin // Keep original value
if req.IsCrossMargin != nil {
@@ -703,6 +731,9 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
"trader_id": traderID,
"trader_name": req.Name,
"ai_model": req.AIModelID,
// A running trader is restarted with the new config (async above), so
// report it as running — callers must not fire a redundant start.
"is_running": wasRunning,
"message": "Trader updated successfully",
})
}
@@ -751,12 +782,23 @@ func (s *Server) handleStartTrader(c *gin.Context) {
traderName = fullCfg.Trader.Name
}
if fullCfg != nil && fullCfg.Exchange != nil && fullCfg.Exchange.ExchangeType == "hyperliquid" && !fullCfg.Exchange.HyperliquidBuilderApproved {
SafeBadRequestWithDetails(c, formatTraderStartError(
fmt.Sprintf("The Hyperliquid trading authorization for bot \"%s\" is not yet complete", traderName),
"Please reconnect the Hyperliquid wallet and complete the trading authorization, then start the bot",
), "trader.start.hyperliquid_builder_not_approved", mapStringPairs("trader_name", traderName, "exchange_name", exchangeDisplayName(fullCfg.Exchange)))
return
}
// Check if trader exists in memory and if it's running
existingTrader, _ := s.traderManager.GetTrader(traderID)
if existingTrader != nil {
status := existingTrader.GetStatus()
if isRunning, ok := status["is_running"].(bool); ok && isRunning {
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader is already running"})
c.JSON(http.StatusBadRequest, gin.H{
"error": "Trader is already running",
"error_key": "trader.start.already_running",
})
return
}
// Trader exists but is stopped - remove from memory to reload fresh config
@@ -782,25 +824,25 @@ func (s *Server) handleStartTrader(c *gin.Context) {
}
// Check AI model
if fullCfg.AIModel == nil {
SafeBadRequestWithDetails(c, formatTraderStartError("这个机器人关联的 AI 模型不存在", "请前往「设置 > 模型配置」检查后,再重新点击启动"), "trader.start.model_not_found", mapStringPairs("trader_name", traderName))
SafeBadRequestWithDetails(c, formatTraderStartError("The AI model associated with this bot does not exist", "Please go to \"Settings > Model Config\" to check, then click start again"), "trader.start.model_not_found", mapStringPairs("trader_name", traderName))
return
}
if !fullCfg.AIModel.Enabled {
SafeBadRequestWithDetails(c, formatTraderStartError(
fmt.Sprintf("机器人「%s」关联的 AI 模型「%s」目前还没有启用", traderName, fullCfg.AIModel.Name),
"请前往「设置 > 模型配置」启用它后,再重新点击启动",
fmt.Sprintf("The AI model \"%s\" associated with bot \"%s\" is not enabled yet", fullCfg.AIModel.Name, traderName),
"Please go to \"Settings > Model Config\" to enable it, then click start again",
), "trader.start.model_disabled", mapStringPairs("trader_name", traderName, "model_name", fullCfg.AIModel.Name))
return
}
// Check exchange
if fullCfg.Exchange == nil {
SafeBadRequestWithDetails(c, formatTraderStartError("这个机器人关联的交易所账户不存在", "请前往「设置 > 交易所配置」检查后,再重新点击启动"), "trader.start.exchange_not_found", mapStringPairs("trader_name", traderName))
SafeBadRequestWithDetails(c, formatTraderStartError("The exchange account associated with this bot does not exist", "Please go to \"Settings > Exchange Config\" to check, then click start again"), "trader.start.exchange_not_found", mapStringPairs("trader_name", traderName))
return
}
if !fullCfg.Exchange.Enabled {
SafeBadRequestWithDetails(c, formatTraderStartError(
fmt.Sprintf("机器人「%s」关联的交易所账户「%s」目前还没有启用", traderName, exchangeDisplayName(fullCfg.Exchange)),
"请前往「设置 > 交易所配置」启用它后,再重新点击启动",
fmt.Sprintf("The exchange account \"%s\" associated with bot \"%s\" is not enabled yet", exchangeDisplayName(fullCfg.Exchange), traderName),
"Please go to \"Settings > Exchange Config\" to enable it, then click start again",
), "trader.start.exchange_disabled", mapStringPairs("trader_name", traderName, "exchange_name", exchangeDisplayName(fullCfg.Exchange)))
return
}
@@ -814,6 +856,24 @@ func (s *Server) handleStartTrader(c *gin.Context) {
return
}
// Server-side launch gate: the trader cannot function without a funded AI
// wallet and a ready exchange account, so verify both before the run loop
// starts. `?force=true` skips the gate for deliberate manual overrides.
if c.Query("force") != "true" {
// strategyRequired=false: a trader that loaded into memory necessarily
// has a valid strategy (the manager refuses to load without one), so the
// preflight strategy check would be redundant here.
preflight := s.runLaunchPreflight(userID, fullCfg.AIModel, fullCfg.Exchange, fullCfg.Strategy, false)
if !preflight.Ready {
c.JSON(http.StatusBadRequest, gin.H{
"error": formatTraderStartError(preflight.Summary(), "Complete the failing checks, then start the bot again"),
"error_key": "trader.start.preflight_failed",
"preflight": preflight,
})
return
}
}
// Start trader
go func() {
logger.Infof("▶️ Starting trader %s (%s)", traderID, trader.GetName())

View File

@@ -267,8 +267,12 @@ func (s *Server) handleClosePosition(c *gin.Context) {
logger.Infof("✅ Position closed successfully: symbol=%s, side=%s, qty=%.6f, result=%v", req.Symbol, req.Side, posQty, result)
// Record order to database (for chart markers and history)
// Backfill the just-closed fill immediately. Manual closes may happen while
// the bot runtime is stopped, so the background OrderSync loop is not enough.
if syncErr := s.syncOrdersAfterManualClose(tempTrader, traderID, exchangeCfg.ID, exchangeCfg.ExchangeType); syncErr != nil {
logger.Infof(" ⚠️ Manual close sync failed: %v", syncErr)
s.recordClosePositionOrder(traderID, exchangeCfg.ID, exchangeCfg.ExchangeType, req.Symbol, req.Side, posQty, entryPrice, result)
}
c.JSON(http.StatusOK, gin.H{
"message": "Position closed successfully",
@@ -278,6 +282,49 @@ func (s *Server) handleClosePosition(c *gin.Context) {
})
}
func (s *Server) syncOrdersFromExchange(exchangeTrader trader.Trader, traderID, exchangeID, exchangeType string) error {
switch t := exchangeTrader.(type) {
case *binance.FuturesTrader:
return t.SyncOrdersFromBinance(traderID, exchangeID, exchangeType, s.store)
case *hyperliquidtrader.HyperliquidTrader:
return t.SyncOrdersFromHyperliquid(traderID, exchangeID, exchangeType, s.store)
case *aster.AsterTrader:
return t.SyncOrdersFromAster(traderID, exchangeID, exchangeType, s.store)
case *bybit.BybitTrader:
return t.SyncOrdersFromBybit(traderID, exchangeID, exchangeType, s.store)
case *okx.OKXTrader:
return t.SyncOrdersFromOKX(traderID, exchangeID, exchangeType, s.store)
case *bitget.BitgetTrader:
return t.SyncOrdersFromBitget(traderID, exchangeID, exchangeType, s.store)
case *gate.GateTrader:
return t.SyncOrdersFromGate(traderID, exchangeID, exchangeType, s.store)
case *kucoin.KuCoinTrader:
return t.SyncOrdersFromKuCoin(traderID, exchangeID, exchangeType, s.store)
case *lighter.LighterTraderV2:
return t.SyncOrdersFromLighter(traderID, exchangeID, exchangeType, s.store)
default:
return fmt.Errorf("order sync is not available for exchange type %s", exchangeType)
}
}
func (s *Server) syncOrdersAfterManualClose(exchangeTrader trader.Trader, traderID, exchangeID, exchangeType string) error {
var lastErr error
for attempt := 1; attempt <= 4; attempt++ {
if attempt > 1 {
time.Sleep(time.Duration(attempt-1) * 500 * time.Millisecond)
}
if err := s.syncOrdersFromExchange(exchangeTrader, traderID, exchangeID, exchangeType); err != nil {
lastErr = err
continue
}
return nil
}
if lastErr != nil {
return lastErr
}
return fmt.Errorf("manual close sync did not run")
}
// recordClosePositionOrder Record close position order to database (Lighter version - direct FILLED status)
func (s *Server) recordClosePositionOrder(traderID, exchangeID, exchangeType, symbol, side string, quantity, exitPrice float64, result map[string]interface{}) {
// Skip for exchanges with OrderSync - let the background sync handle it to avoid duplicates

View File

@@ -0,0 +1,28 @@
package api
import "testing"
func TestIsSupportedTraderSymbol(t *testing.T) {
tests := []struct {
name string
symbol string
want bool
}{
{name: "legacy USDT perp", symbol: "BTCUSDT", want: true},
{name: "legacy USDT perp lowercase", symbol: "ethusdt", want: true},
{name: "Hyperliquid xyz stock USDC pair", symbol: "SMSN-USDC", want: true},
{name: "Hyperliquid xyz commodity USDC pair", symbol: "GOLD-USDC", want: true},
{name: "legacy internal xyz prefix still accepted", symbol: "xyz:SMSN", want: true},
{name: "empty slot ignored", symbol: " ", want: true},
{name: "bare stock without xyz prefix rejected", symbol: "SMSN", want: false},
{name: "unknown non-USDT pair rejected", symbol: "BTCUSD", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isSupportedTraderSymbol(tt.symbol); got != tt.want {
t.Fatalf("isSupportedTraderSymbol(%q) = %v, want %v", tt.symbol, got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,17 @@
package api
import "testing"
func TestValidateTraderLeverageRangeMatchesManualLimits(t *testing.T) {
if msg, code := validateTraderLeverageRange(20, 20); msg != "" || code != "" {
t.Fatalf("expected 20/20 leverage to be accepted, got msg=%q code=%q", msg, code)
}
if msg, code := validateTraderLeverageRange(21, 20); msg == "" || code != "trader.create.invalid_btc_eth_leverage" {
t.Fatalf("expected BTC/ETH leverage > 20 to be rejected, got msg=%q code=%q", msg, code)
}
if msg, code := validateTraderLeverageRange(20, 21); msg == "" || code != "trader.create.invalid_altcoin_leverage" {
t.Fatalf("expected altcoin leverage > 20 to be rejected, got msg=%q code=%q", msg, code)
}
}

View File

@@ -60,7 +60,7 @@ func (s *Server) handleRegister(c *gin.Context) {
var req struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
Password string `json:"password" binding:"required,min=8"`
Lang string `json:"lang"`
}
@@ -102,9 +102,11 @@ func (s *Server) handleRegister(c *gin.Context) {
return
}
// Adopt orphan records from previous account (e.g. after account reset)
// This preserves wallet keys and exchange configs so funds are not lost.
s.adoptOrphanRecords(userID)
// NOTE: Orphan record adoption was removed for security reasons. Previously,
// after a reset-account call, any new user would inherit the prior owner's
// wallet keys and exchange API credentials — a catastrophic IDOR/takeover
// path. Operators who need to migrate credentials across users must do so
// explicitly via export/import, never via implicit adoption on registration.
// Generate JWT token
token, err := auth.GenerateJWT(user.ID, user.Email)
@@ -127,6 +129,13 @@ func (s *Server) handleRegister(c *gin.Context) {
})
}
// dummyPasswordHash is a valid bcrypt hash of a throwaway value. It is compared
// against when the submitted email does not exist so that login takes roughly
// the same time whether or not the account exists — closing the timing side
// channel that would otherwise let an attacker enumerate valid emails (a fast
// "no such user" vs. a slow bcrypt compare). It is not a secret.
const dummyPasswordHash = "$2a$10$0iF0bCoQLJ6Ph1bF.MXwHOW.IMTxQjeEW.w38dctRQAB2kwB6ga1q"
// handleLogin Handle user login request
func (s *Server) handleLogin(c *gin.Context) {
var req struct {
@@ -142,6 +151,9 @@ func (s *Server) handleLogin(c *gin.Context) {
// Get user information
user, err := s.store.User().GetByEmail(req.Email)
if err != nil {
// Perform a dummy comparison so the response time does not reveal
// whether the email exists (anti user-enumeration), then fail uniformly.
auth.CheckPassword(req.Password, dummyPasswordHash)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Email or password incorrect"})
return
}
@@ -189,86 +201,14 @@ func (s *Server) handleChangePassword(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Password updated"})
}
// handleResetPassword Reset password via email and new password
func (s *Server) handleResetPassword(c *gin.Context) {
var req struct {
Email string `json:"email" binding:"required,email"`
NewPassword string `json:"new_password" binding:"required,min=6"`
}
if err := c.ShouldBindJSON(&req); err != nil {
SafeBadRequest(c, "Invalid request parameters")
return
}
// Query user
user, err := s.store.User().GetByEmail(req.Email)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Email does not exist"})
return
}
// Generate new password hash
newPasswordHash, err := auth.HashPassword(req.NewPassword)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Password processing failed"})
return
}
// Update password
err = s.store.User().UpdatePassword(user.ID, newPasswordHash)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Password update failed"})
return
}
logger.Infof("✓ User %s password has been reset", user.Email)
c.JSON(http.StatusOK, gin.H{"message": "Password reset successful, please login with new password"})
}
// handleResetAccount clears user authentication data so the system returns to
// uninitialized state for re-registration. Wallet keys (ai_models) are preserved
// so funds are not lost — they will be adopted by the new account during onboarding.
func (s *Server) handleResetAccount(c *gin.Context) {
err := s.store.Transaction(func(tx *gorm.DB) error {
// Delete traders and strategies (config, not funds)
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Trader{})
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Strategy{})
// Delete users — ai_models and exchanges are intentionally kept
// so wallet private keys and exchange configs survive re-registration
if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.User{}).Error; err != nil {
return fmt.Errorf("failed to delete users: %w", err)
}
return nil
})
if err != nil {
SafeInternalError(c, "Failed to reset account", err)
return
}
logger.Infof("✓ User accounts cleared (wallets preserved) — system reset to uninitialized")
c.JSON(http.StatusOK, gin.H{"message": "Account reset successful, you can now register a new account"})
}
// adoptOrphanRecords re-assigns ai_models and exchanges whose user_id no longer
// exists in the users table. This happens after account reset so the new user
// inherits the previous wallet keys and exchange configurations.
func (s *Server) adoptOrphanRecords(newUserID string) {
db := s.store.GormDB()
result := db.Model(&store.AIModel{}).
Where("user_id NOT IN (SELECT id FROM users)").
Update("user_id", newUserID)
if result.RowsAffected > 0 {
logger.Infof("✓ Adopted %d orphan ai_model(s) for new user %s", result.RowsAffected, newUserID)
}
result = db.Model(&store.Exchange{}).
Where("user_id NOT IN (SELECT id FROM users)").
Update("user_id", newUserID)
if result.RowsAffected > 0 {
logger.Infof("✓ Adopted %d orphan exchange(s) for new user %s", result.RowsAffected, newUserID)
}
}
// NOTE: Password and account recovery used to live here as the public,
// unauthenticated handlers handleResetPassword / handleResetAccount. They were
// removed because an unauthenticated recovery endpoint is a remotely
// exploitable auth-bypass on any public-facing deployment: the confirm phrase
// was embedded in the frontend (and echoed back by the API), so it was friction
// rather than authentication. Recovery now lives in the local CLI
// (`nofx reset-password` / `nofx reset-account`, see cli.go), which requires
// shell access to the host — something a remote attacker does not have.
// initUserDefaultConfigs Initialize default configs for new user
func (s *Server) initUserDefaultConfigs(userID string, lang string) error {
@@ -285,23 +225,17 @@ func (s *Server) createDefaultStrategies(userID string, lang string) error {
name, description string
}
type strategyLocale struct {
balanced, conservative, aggressive strategyI18n
defaultStrategy strategyI18n
}
locales := map[string]strategyLocale{
"zh": {
balanced: strategyI18n{"均衡策略", "系统默认策略。均衡风险收益适合大多数市场环境。5倍杠杆最多3个仓位。"},
conservative: strategyI18n{"稳健策略", "系统默认策略。低杠杆保守操作优先保护本金。3倍杠杆专注主流资产。"},
aggressive: strategyI18n{"积极策略", "系统默认策略。高杠杆主动交易更广泛的币种选择适合经验丰富的交易者。10倍杠杆最多5个仓位。"},
defaultStrategy: strategyI18n{"NOFX Claw402 Auto Strategy", "The only built-in strategy: read the Claw402.ai board each cycle, fetch Signal Lab and cost/liquidation heatmap per candidate, then decide with raw candles."},
},
"en": {
balanced: strategyI18n{"Balanced Strategy", "System default strategy. Balanced risk-reward, suitable for most market conditions. 5x leverage, up to 3 positions."},
conservative: strategyI18n{"Conservative Strategy", "System default strategy. Low-leverage conservative trading, capital preservation first. 3x leverage, focused on major assets."},
aggressive: strategyI18n{"Aggressive Strategy", "System default strategy. High-leverage active trading, wider asset selection, for experienced traders. 10x leverage, up to 5 positions."},
defaultStrategy: strategyI18n{"NOFX Claw402 Auto Strategy", "The only built-in strategy: read the Claw402.ai board each cycle, fetch Signal Lab and cost/liquidation heatmap per candidate, then decide with raw candles."},
},
"id": {
balanced: strategyI18n{"Strategi Seimbang", "Strategi default sistem. Risiko-reward seimbang, cocok untuk sebagian besar kondisi pasar. Leverage 5x, hingga 3 posisi."},
conservative: strategyI18n{"Strategi Konservatif", "Strategi default sistem. Trading konservatif leverage rendah, utamakan perlindungan modal. Leverage 3x, fokus aset utama."},
aggressive: strategyI18n{"Strategi Agresif", "Strategi default sistem. Trading aktif leverage tinggi, pilihan aset lebih luas, untuk trader berpengalaman. Leverage 10x, hingga 5 posisi."},
defaultStrategy: strategyI18n{"Strategi Otomatis NOFX Claw402", "Satu strategi bawaan: membaca papan Claw402.ai, mengambil Signal Lab dan heatmap biaya/likuidasi per kandidat, lalu memutuskan dengan candle mentah."},
},
}
locale, ok := locales[lang]
@@ -316,45 +250,45 @@ func (s *Server) createDefaultStrategies(userID string, lang string) error {
applyConfig func(*store.StrategyConfig)
}
setClaw402Strategy := func(c *store.StrategyConfig) {
c.CoinSource.SourceType = "vergex_signal"
c.CoinSource.StaticCoins = nil
c.CoinSource.UseAI500 = false
c.CoinSource.UseOITop = false
c.CoinSource.UseOILow = false
c.CoinSource.UseHyperAll = false
c.CoinSource.UseHyperMain = false
c.CoinSource.HyperRankCategory = "all"
c.CoinSource.VergexLimit = 10
c.CoinSource.VergexMarketType = "all"
c.CoinSource.VergexChain = "hyperliquid"
c.RiskControl.MaxPositions = 2
c.RiskControl.BTCETHMaxLeverage = 5
c.RiskControl.AltcoinMaxLeverage = 5
// Few, concentrated positions held for big moves. 5x leverage so a
// wide (-5%) stop is survivable rather than an instant liquidation;
// 2 positions × 2.5x = 5x total notional (full margin, ~20% cushion).
c.RiskControl.BTCETHMaxPositionValueRatio = 2.5
c.RiskControl.AltcoinMaxPositionValueRatio = 2.5
c.RiskControl.MaxMarginUsage = 1.0
c.RiskControl.MinConfidence = 78
c.RiskControl.MinRiskRewardRatio = 3.0
c.Indicators.Klines.PrimaryTimeframe = "15m"
c.Indicators.Klines.PrimaryCount = 30
c.Indicators.Klines.LongerTimeframe = ""
c.Indicators.Klines.LongerCount = 0
c.Indicators.Klines.EnableMultiTimeframe = false
c.Indicators.Klines.SelectedTimeframes = []string{"15m"}
c.Indicators.EnableRawKlines = true
}
definitions := []strategyDef{
{
name: locale.balanced.name,
description: locale.balanced.description,
name: locale.defaultStrategy.name,
description: locale.defaultStrategy.description,
isActive: true,
applyConfig: func(c *store.StrategyConfig) {
// Uses default config as-is
},
},
{
name: locale.conservative.name,
description: locale.conservative.description,
isActive: false,
applyConfig: func(c *store.StrategyConfig) {
c.RiskControl.BTCETHMaxLeverage = 3
c.RiskControl.AltcoinMaxLeverage = 3
c.RiskControl.BTCETHMaxPositionValueRatio = 3.0
c.RiskControl.AltcoinMaxPositionValueRatio = 0.5
c.RiskControl.MinConfidence = 80
c.RiskControl.MinRiskRewardRatio = 4.0
c.Indicators.Klines.SelectedTimeframes = []string{"15m", "1h", "4h"}
c.Indicators.Klines.PrimaryTimeframe = "15m"
},
},
{
name: locale.aggressive.name,
description: locale.aggressive.description,
isActive: false,
applyConfig: func(c *store.StrategyConfig) {
c.RiskControl.BTCETHMaxLeverage = 10
c.RiskControl.AltcoinMaxLeverage = 7
c.RiskControl.MaxPositions = 5
c.RiskControl.AltcoinMaxPositionValueRatio = 2.0
c.RiskControl.MinConfidence = 70
c.CoinSource.AI500Limit = 5
c.CoinSource.UseOITop = true
c.CoinSource.OITopLimit = 5
c.Indicators.Klines.SelectedTimeframes = []string{"3m", "15m", "1h"}
c.Indicators.Klines.PrimaryTimeframe = "3m"
setClaw402Strategy(c)
},
},
}
@@ -370,6 +304,7 @@ func (s *Server) createDefaultStrategies(userID string, lang string) error {
for _, def := range definitions {
config := store.GetDefaultStrategyConfig(configLang)
def.applyConfig(&config)
config.ClampLimits()
strategy := &store.Strategy{
ID: uuid.New().String(),
@@ -385,11 +320,49 @@ func (s *Server) createDefaultStrategies(userID string, lang string) error {
strategies = append(strategies, strategy)
}
legacyDefaultNames := []string{
"Balanced Strategy", "Steady Strategy", "Aggressive Strategy",
"US Stock Trend Strategy", "US Stock Steady Strategy", "US Stock Breakout Strategy",
"Balanced Strategy", "Conservative Strategy", "Aggressive Strategy",
"US Stock Trend Strategy", "US Stock Steady Strategy", "US Stock Breakout Strategy",
"Strategi Seimbang", "Strategi Konservatif", "Strategi Agresif",
"Strategi Tren Saham AS", "Strategi Stabil Saham AS", "Strategi Breakout Saham AS",
}
return s.store.Transaction(func(tx *gorm.DB) error {
// Remove obsolete built-in risk-profile presets for this user. If a trader still
// references one of them, keep it to avoid breaking an existing running setup.
deleteResult := tx.Where("user_id = ? AND name IN ? AND id NOT IN (SELECT strategy_id FROM traders WHERE user_id = ? AND strategy_id IS NOT NULL)", userID, legacyDefaultNames, userID).
Delete(&store.Strategy{})
if deleteResult.Error != nil {
return fmt.Errorf("failed to remove legacy default strategies: %w", deleteResult.Error)
}
if deleteResult.RowsAffected > 0 {
logger.Infof(" ✓ Removed %d legacy default strategy preset(s)", deleteResult.RowsAffected)
}
var activeCount int64
if err := tx.Model(&store.Strategy{}).Where("user_id = ? AND is_active = ?", userID, true).Count(&activeCount).Error; err != nil {
return fmt.Errorf("failed to count active strategies: %w", err)
}
for _, strategy := range strategies {
var existing int64
if err := tx.Model(&store.Strategy{}).Where("user_id = ? AND name = ?", userID, strategy.Name).Count(&existing).Error; err != nil {
return fmt.Errorf("failed to check strategy %q: %w", strategy.Name, err)
}
if existing > 0 {
continue
}
if activeCount > 0 {
strategy.IsActive = false
}
if err := tx.Create(strategy).Error; err != nil {
return fmt.Errorf("failed to create strategy %q: %w", strategy.Name, err)
}
if strategy.IsActive {
activeCount++
}
logger.Infof(" ✓ Created default strategy: %s (active=%v)", strategy.Name, strategy.IsActive)
}
return nil

View File

@@ -0,0 +1,136 @@
package api
import (
"testing"
"github.com/google/uuid"
"nofx/store"
)
func TestCreateDefaultStrategiesUsesOneReadyToRunClaw402Preset(t *testing.T) {
st, err := store.New(t.TempDir() + "/nofx.db")
if err != nil {
t.Fatalf("store.New failed: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
s := &Server{store: st}
userID := "user-us-stock-presets"
if err := s.createDefaultStrategies(userID, "zh"); err != nil {
t.Fatalf("createDefaultStrategies failed: %v", err)
}
strategies, err := st.Strategy().List(userID)
if err != nil {
t.Fatalf("List strategies failed: %v", err)
}
if len(strategies) != 1 {
t.Fatalf("expected 1 default strategy, got %d", len(strategies))
}
byName := map[string]*store.Strategy{}
activeCount := 0
for _, strategy := range strategies {
byName[strategy.Name] = strategy
if strategy.IsActive {
activeCount++
}
if strategy.Name == "Balanced Strategy" || strategy.Name == "Steady Strategy" || strategy.Name == "Aggressive Strategy" {
t.Fatalf("legacy crypto-style default strategy still present: %s", strategy.Name)
}
}
if activeCount != 1 {
t.Fatalf("expected exactly one active strategy, got %d", activeCount)
}
defaultStrategy := byName["NOFX Claw402 Auto Strategy"]
if defaultStrategy == nil || !defaultStrategy.IsActive {
t.Fatalf("NOFX Claw402 Auto Strategy should exist and be active")
}
trendCfg, err := defaultStrategy.ParseConfig()
if err != nil {
t.Fatalf("default ParseConfig failed: %v", err)
}
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)
}
if trendCfg.CoinSource.UseAI500 || trendCfg.RiskControl.MaxPositions != 2 {
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 != 5 || trendCfg.RiskControl.AltcoinMaxLeverage != 5 {
t.Fatalf("default strategy should use 5x leverage for all Claw402 opens, got risk=%+v", trendCfg.RiskControl)
}
if trendCfg.RiskControl.BTCETHMaxPositionValueRatio != 2.5 ||
trendCfg.RiskControl.AltcoinMaxPositionValueRatio != 2.5 ||
trendCfg.RiskControl.MaxMarginUsage != 1.0 {
t.Fatalf("default strategy should size Claw402 opens at 2.5x equity notional (2 positions = 5x total at 5x), got risk=%+v", trendCfg.RiskControl)
}
}
func TestCreateDefaultStrategiesMigratesLegacyPresetsWithoutOverridingActiveCustom(t *testing.T) {
st, err := store.New(t.TempDir() + "/nofx.db")
if err != nil {
t.Fatalf("store.New failed: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
userID := "user-existing-custom"
legacyCfg := store.GetDefaultStrategyConfig("zh")
legacy := &store.Strategy{
ID: uuid.New().String(),
UserID: userID,
Name: "Balanced Strategy",
Description: "legacy",
IsActive: false,
}
if err := legacy.SetConfig(&legacyCfg); err != nil {
t.Fatalf("legacy SetConfig failed: %v", err)
}
if err := st.Strategy().Create(legacy); err != nil {
t.Fatalf("create legacy failed: %v", err)
}
custom := &store.Strategy{
ID: uuid.New().String(),
UserID: userID,
Name: "aa",
Description: "user custom active strategy",
IsActive: true,
}
if err := custom.SetConfig(&legacyCfg); err != nil {
t.Fatalf("custom SetConfig failed: %v", err)
}
if err := st.Strategy().Create(custom); err != nil {
t.Fatalf("create custom failed: %v", err)
}
s := &Server{store: st}
if err := s.createDefaultStrategies(userID, "zh"); err != nil {
t.Fatalf("createDefaultStrategies failed: %v", err)
}
if err := s.createDefaultStrategies(userID, "zh"); err != nil {
t.Fatalf("second createDefaultStrategies should be idempotent: %v", err)
}
strategies, err := st.Strategy().List(userID)
if err != nil {
t.Fatalf("List strategies failed: %v", err)
}
byName := map[string]int{}
activeNames := []string{}
for _, strategy := range strategies {
byName[strategy.Name]++
if strategy.IsActive {
activeNames = append(activeNames, strategy.Name)
}
}
if byName["Balanced Strategy"] != 0 {
t.Fatalf("legacy preset should be removed, got names=%+v", byName)
}
if byName["NOFX Claw402 Auto Strategy"] != 1 {
t.Fatalf("expected exactly one NOFX Claw402 Auto Strategy, got names=%+v", byName)
}
if len(activeNames) != 1 || activeNames[0] != "aa" {
t.Fatalf("existing active custom strategy should stay the only active one, got %+v", activeNames)
}
}

137
api/handler_vergex.go Normal file
View File

@@ -0,0 +1,137 @@
package api
import (
"context"
"fmt"
"net/http"
"nofx/logger"
"nofx/provider/vergex"
"strings"
"github.com/gin-gonic/gin"
)
func (s *Server) handleVergexSignalRanking(c *gin.Context) {
client, ok := s.newVergexClientForRequest(c)
if !ok {
return
}
data, err := client.GetSignalRanking(context.Background(), vergex.Query{
Chain: strings.TrimSpace(c.Query("chain")),
LiqBand: strings.TrimSpace(c.Query("liqBand")),
})
if err != nil {
logger.Warnf("Vergex signal-ranking failed: %v", err)
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
limit := parsePositiveInt(c.Query("limit"), vergex.MaxSignalRankingItems)
marketType := strings.TrimSpace(c.Query("marketType"))
items := vergex.FilterSignalRankingItems(data.Items, marketType, limit)
c.JSON(http.StatusOK, gin.H{
"items": items,
"raw": data.Raw,
})
}
func (s *Server) handleVergexSignalLab(c *gin.Context) {
client, ok := s.newVergexClientForRequest(c)
if !ok {
return
}
body, err := client.GetSignalLab(context.Background(), vergex.Query{
MarketType: withDefault(strings.TrimSpace(c.Query("marketType")), vergex.DefaultMarketType),
Symbol: strings.TrimSpace(c.Query("symbol")),
Chain: strings.TrimSpace(c.Query("chain")),
LiqBand: strings.TrimSpace(c.Query("liqBand")),
})
if err != nil {
logger.Warnf("Vergex signal-lab failed: %v", err)
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.Data(http.StatusOK, "application/json; charset=utf-8", body)
}
func (s *Server) handleVergexCostLiquidationHeatmap(c *gin.Context) {
client, ok := s.newVergexClientForRequest(c)
if !ok {
return
}
body, err := client.GetCostLiquidationHeatmap(context.Background(), vergex.Query{
MarketType: withDefault(strings.TrimSpace(c.Query("marketType")), vergex.DefaultMarketType),
Symbol: strings.TrimSpace(c.Query("symbol")),
Chain: strings.TrimSpace(c.Query("chain")),
LiqBand: strings.TrimSpace(c.Query("liqBand")),
})
if err != nil {
logger.Warnf("Vergex cost-liquidation-heatmap failed: %v", err)
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.Data(http.StatusOK, "application/json; charset=utf-8", body)
}
// handleVergexFlowMarkets proxies the Vergex net-flow market ranking (paid x402
// endpoint) using the caller's claw402 wallet. The upstream JSON is passed
// through verbatim: { data: { window, by, inflow: [{ symbol, netFlow,
// buyNotional, sellNotional, trades, latestPrice }, ...] } }.
func (s *Server) handleVergexFlowMarkets(c *gin.Context) {
client, ok := s.newVergexClientForRequest(c)
if !ok {
return
}
chain := withDefault(strings.TrimSpace(c.Query("chain")), "mainnet")
window := withDefault(strings.TrimSpace(c.Query("window")), "1h")
limit := parsePositiveInt(c.Query("limit"), 25)
body, err := client.GetFlowMarkets(context.Background(), chain, window, limit)
if err != nil {
logger.Warnf("Vergex flow-markets failed: %v", err)
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.Data(http.StatusOK, "application/json; charset=utf-8", body)
}
func (s *Server) newVergexClientForRequest(c *gin.Context) (*vergex.Client, bool) {
userID := c.GetString("user_id")
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return nil, false
}
walletKey, err := s.resolveStrategyDataWalletKey(userID, c.Query("ai_model_id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return nil, false
}
if walletKey == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "claw402 wallet is not configured"})
return nil, false
}
client, err := vergex.NewClient("", walletKey, &logger.MCPLogger{})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return nil, false
}
return client, true
}
func parsePositiveInt(raw string, fallback int) int {
if raw == "" {
return fallback
}
var n int
if _, err := fmt.Sscanf(raw, "%d", &n); err != nil || n <= 0 {
return fallback
}
return n
}
func withDefault(value, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return value
}

375
api/launch_preflight.go Normal file
View File

@@ -0,0 +1,375 @@
package api
import (
"fmt"
"net/http"
"strings"
"time"
"nofx/store"
"nofx/wallet"
"github.com/gin-gonic/gin"
)
// Launch readiness minimums. These are the single source of truth — the
// frontend reads them from the preflight response instead of hardcoding.
const (
// MinAIFeeUSDC is the minimum Base USDC a claw402 fee wallet needs so the
// trader can pay for its first AI/data calls.
MinAIFeeUSDC = 1.0
// MinTradingUSDC is the minimum available balance the exchange account
// needs before the trader can place its first order.
MinTradingUSDC = 12.0
)
const (
launchCheckStatusOK = "ok"
launchCheckStatusFailed = "failed"
launchCheckStatusWarning = "warning"
launchCheckStatusSkipped = "skipped"
)
// Check IDs — stable identifiers the frontend maps to guided-setup steps.
const (
launchCheckAIModel = "ai_model"
launchCheckAIWallet = "ai_wallet"
launchCheckAIWalletFunds = "ai_wallet_funds"
launchCheckStrategy = "strategy"
launchCheckExchangeConfig = "exchange_config"
launchCheckExchangeAccount = "exchange_account"
launchCheckExchangeFunds = "exchange_funds"
)
// queryAIWalletBalance is swappable in tests to avoid live Base RPC calls.
var queryAIWalletBalance = wallet.QueryUSDCBalanceCached
type LaunchCheck struct {
ID string `json:"id"`
Status string `json:"status"`
Code string `json:"code,omitempty"`
Message string `json:"message,omitempty"`
// Numeric context so the UI can render progress like "8.40 / 12 USDC".
Required float64 `json:"required,omitempty"`
Actual *float64 `json:"actual,omitempty"`
Asset string `json:"asset,omitempty"`
// Address is the funding address for balance checks, so the UI can offer
// a deposit shortcut next to the failing item.
Address string `json:"address,omitempty"`
}
type LaunchPreflightResult struct {
Ready bool `json:"ready"`
Checks []LaunchCheck `json:"checks"`
MinAIFeeUSDC float64 `json:"min_ai_fee_usdc"`
MinTradingUSDC float64 `json:"min_trading_usdc"`
CheckedAt time.Time `json:"checked_at"`
}
func (r LaunchPreflightResult) failedChecks() []LaunchCheck {
var failed []LaunchCheck
for _, check := range r.Checks {
if check.Status == launchCheckStatusFailed {
failed = append(failed, check)
}
}
return failed
}
// Summary joins the failing messages into one human-readable sentence.
func (r LaunchPreflightResult) Summary() string {
failed := r.failedChecks()
if len(failed) == 0 {
return ""
}
messages := make([]string, 0, len(failed))
for _, check := range failed {
if check.Message != "" {
messages = append(messages, check.Message)
}
}
return strings.Join(messages, " ")
}
type launchPreflightRequest struct {
AIModelID string `json:"ai_model_id"`
ExchangeID string `json:"exchange_id"`
StrategyID string `json:"strategy_id"`
}
// handleLaunchPreflight runs launch readiness checks for a model/exchange
// pair before any trader is created or mutated. POST /api/launch/preflight
func (s *Server) handleLaunchPreflight(c *gin.Context) {
userID := c.GetString("user_id")
var req launchPreflightRequest
if err := c.ShouldBindJSON(&req); err != nil {
SafeBadRequest(c, "Invalid preflight request")
return
}
if strings.TrimSpace(req.AIModelID) == "" || strings.TrimSpace(req.ExchangeID) == "" {
SafeBadRequest(c, "ai_model_id and exchange_id are required")
return
}
model, err := s.store.AIModel().Get(userID, req.AIModelID)
if err != nil {
model = nil
}
exchange, err := s.store.Exchange().GetByID(userID, req.ExchangeID)
if err != nil {
exchange = nil
}
var strategy *store.Strategy
strategyRequired := strings.TrimSpace(req.StrategyID) != ""
if strategyRequired {
strategy, err = s.store.Strategy().Get(userID, req.StrategyID)
if err != nil {
strategy = nil
}
}
result := s.runLaunchPreflight(userID, model, exchange, strategy, strategyRequired)
c.JSON(http.StatusOK, result)
}
// handleTraderPreflight runs the same checks against an existing trader's
// full configuration. GET /api/traders/:id/preflight
func (s *Server) handleTraderPreflight(c *gin.Context) {
userID := c.GetString("user_id")
traderID := c.Param("id")
fullCfg, err := s.store.Trader().GetFullConfig(userID, traderID)
if err != nil || fullCfg == nil || fullCfg.Trader == nil {
SafeNotFound(c, "Trader")
return
}
result := s.runLaunchPreflight(userID, fullCfg.AIModel, fullCfg.Exchange, fullCfg.Strategy, true)
c.JSON(http.StatusOK, result)
}
// runLaunchPreflight composes every launch readiness check. A check that
// cannot be evaluated (RPC outage, probe timeout) reports "warning" instead
// of "failed" so an infrastructure hiccup never hard-blocks a launch.
func (s *Server) runLaunchPreflight(
userID string,
model *store.AIModel,
exchange *store.Exchange,
strategy *store.Strategy,
strategyRequired bool,
) LaunchPreflightResult {
checks := []LaunchCheck{checkLaunchAIModel(model)}
checks = append(checks, checkLaunchAIWallet(model)...)
checks = append(checks, checkLaunchStrategy(strategy, strategyRequired))
checks = append(checks, s.checkLaunchExchange(userID, exchange)...)
ready := true
for _, check := range checks {
if check.Status == launchCheckStatusFailed {
ready = false
break
}
}
return LaunchPreflightResult{
Ready: ready,
Checks: checks,
MinAIFeeUSDC: MinAIFeeUSDC,
MinTradingUSDC: MinTradingUSDC,
CheckedAt: time.Now().UTC(),
}
}
func checkLaunchAIModel(model *store.AIModel) LaunchCheck {
check := LaunchCheck{ID: launchCheckAIModel}
switch {
case model == nil:
check.Status = launchCheckStatusFailed
check.Code = "MODEL_NOT_FOUND"
check.Message = "The selected AI model was not found. Configure an AI model first."
case !model.Enabled:
check.Status = launchCheckStatusFailed
check.Code = "MODEL_DISABLED"
check.Message = fmt.Sprintf("AI model \"%s\" is disabled. Enable it first.", model.Name)
case strings.TrimSpace(model.APIKey.String()) == "":
check.Status = launchCheckStatusFailed
check.Code = "MODEL_MISSING_CREDENTIALS"
check.Message = fmt.Sprintf("AI model \"%s\" has no credential saved. Add the API key or wallet key first.", model.Name)
default:
check.Status = launchCheckStatusOK
check.Message = model.Name
}
return check
}
// checkLaunchAIWallet validates the claw402 fee wallet (address + Base USDC
// balance). Non-claw402 providers pay per API key, so both checks are skipped.
func checkLaunchAIWallet(model *store.AIModel) []LaunchCheck {
walletCheck := LaunchCheck{ID: launchCheckAIWallet}
fundsCheck := LaunchCheck{ID: launchCheckAIWalletFunds, Asset: "USDC", Required: MinAIFeeUSDC}
if model == nil || model.Provider != "claw402" || strings.TrimSpace(model.APIKey.String()) == "" {
walletCheck.Status = launchCheckStatusSkipped
fundsCheck.Status = launchCheckStatusSkipped
return []LaunchCheck{walletCheck, fundsCheck}
}
address, err := walletAddressFromPrivateKey(model.APIKey.String())
if err != nil {
walletCheck.Status = launchCheckStatusFailed
walletCheck.Code = "AI_WALLET_INVALID_KEY"
walletCheck.Message = "The Claw402 wallet key is invalid. Recreate the Base USDC payment wallet."
fundsCheck.Status = launchCheckStatusSkipped
return []LaunchCheck{walletCheck, fundsCheck}
}
walletCheck.Status = launchCheckStatusOK
walletCheck.Address = address
fundsCheck.Address = address
balance, err := queryAIWalletBalance(address)
if err != nil {
fundsCheck.Status = launchCheckStatusWarning
fundsCheck.Code = "AI_WALLET_BALANCE_UNKNOWN"
fundsCheck.Message = "Could not verify the Base USDC balance right now. The trader will start, but AI calls fail if the wallet is empty."
return []LaunchCheck{walletCheck, fundsCheck}
}
fundsCheck.Actual = &balance
if balance < MinAIFeeUSDC {
fundsCheck.Status = launchCheckStatusFailed
fundsCheck.Code = "AI_WALLET_INSUFFICIENT_FUNDS"
fundsCheck.Message = fmt.Sprintf(
"The Claw402 wallet holds %.2f USDC but needs at least %.0f USDC on Base to pay for AI and data calls.",
balance, MinAIFeeUSDC,
)
} else {
fundsCheck.Status = launchCheckStatusOK
}
return []LaunchCheck{walletCheck, fundsCheck}
}
func checkLaunchStrategy(strategy *store.Strategy, required bool) LaunchCheck {
check := LaunchCheck{ID: launchCheckStrategy}
switch {
case strategy != nil:
check.Status = launchCheckStatusOK
check.Message = strategy.Name
case required:
check.Status = launchCheckStatusFailed
check.Code = "STRATEGY_NOT_FOUND"
check.Message = "The selected strategy was not found. Pick or create a strategy first."
default:
check.Status = launchCheckStatusSkipped
}
return check
}
// checkLaunchExchange validates exchange configuration completeness and then
// probes the live account (30s server cache) for status and balance.
func (s *Server) checkLaunchExchange(userID string, exchange *store.Exchange) []LaunchCheck {
configCheck := LaunchCheck{ID: launchCheckExchangeConfig}
accountCheck := LaunchCheck{ID: launchCheckExchangeAccount}
fundsCheck := LaunchCheck{ID: launchCheckExchangeFunds, Required: MinTradingUSDC}
if msg, code := describeExchangeConfigIssue(exchange); code != "" {
configCheck.Status = launchCheckStatusFailed
configCheck.Code = code
configCheck.Message = msg
accountCheck.Status = launchCheckStatusSkipped
fundsCheck.Status = launchCheckStatusSkipped
return []LaunchCheck{configCheck, accountCheck, fundsCheck}
}
configCheck.Status = launchCheckStatusOK
configCheck.Message = exchangeDisplayName(exchange)
fundsCheck.Asset = accountAssetForExchange(exchange.ExchangeType)
states, err := s.getExchangeAccountStates(userID)
if err != nil {
accountCheck.Status = launchCheckStatusWarning
accountCheck.Code = "EXCHANGE_STATE_UNKNOWN"
accountCheck.Message = "Could not verify the exchange account right now."
fundsCheck.Status = launchCheckStatusSkipped
return []LaunchCheck{configCheck, accountCheck, fundsCheck}
}
state, ok := states[exchange.ID]
if !ok {
accountCheck.Status = launchCheckStatusWarning
accountCheck.Code = "EXCHANGE_STATE_UNKNOWN"
accountCheck.Message = "Could not verify the exchange account right now."
fundsCheck.Status = launchCheckStatusSkipped
return []LaunchCheck{configCheck, accountCheck, fundsCheck}
}
if state.Status != exchangeAccountStatusOK {
accountCheck.Status = launchCheckStatusFailed
accountCheck.Code = state.ErrorCode
accountCheck.Message = state.ErrorMessage
if accountCheck.Message == "" {
accountCheck.Message = fmt.Sprintf("Exchange account \"%s\" is not ready (%s).", exchangeDisplayName(exchange), state.Status)
}
fundsCheck.Status = launchCheckStatusSkipped
return []LaunchCheck{configCheck, accountCheck, fundsCheck}
}
accountCheck.Status = launchCheckStatusOK
accountCheck.Message = state.DisplayBalance
// Gate on the better of available balance and total equity: a bot with
// capital deployed in open positions has low *available* margin but is
// clearly funded — restarting it must not be blocked.
funded := state.AvailableBalance
if state.TotalEquity > funded {
funded = state.TotalEquity
}
fundsCheck.Actual = &funded
if funded < MinTradingUSDC {
message := fmt.Sprintf(
"Exchange account \"%s\" holds %.2f %s but needs at least %.0f %s to place the first trade.",
exchangeDisplayName(exchange), funded, fundsCheck.Asset, MinTradingUSDC, fundsCheck.Asset,
)
if exchange.Testnet {
// Testnet balances are play money — warn instead of block.
fundsCheck.Status = launchCheckStatusWarning
} else {
fundsCheck.Status = launchCheckStatusFailed
}
fundsCheck.Code = "EXCHANGE_INSUFFICIENT_FUNDS"
fundsCheck.Message = message
} else {
fundsCheck.Status = launchCheckStatusOK
}
return []LaunchCheck{configCheck, accountCheck, fundsCheck}
}
// describeExchangeConfigIssue mirrors the create-time exchange validation but
// returns stable uppercase codes for the checklist UI.
func describeExchangeConfigIssue(exchange *store.Exchange) (string, string) {
if exchange == nil {
return "The selected exchange account was not found. Connect an exchange first.", "EXCHANGE_NOT_FOUND"
}
if !exchange.Enabled {
return fmt.Sprintf("Exchange account \"%s\" is disabled. Enable it first.", exchangeDisplayName(exchange)), "EXCHANGE_DISABLED"
}
if missing := missingExchangeFields(exchange); len(missing) > 0 {
return fmt.Sprintf(
"Exchange account \"%s\" is missing %s. Complete the connection first.",
exchangeDisplayName(exchange), strings.Join(missing, ", "),
), "EXCHANGE_MISSING_FIELDS"
}
if exchange.ExchangeType == "hyperliquid" && !exchange.HyperliquidBuilderApproved {
return "Hyperliquid builder authorization is not complete. Reconnect the wallet and finish the authorization.", "HYPERLIQUID_BUILDER_NOT_APPROVED"
}
return "", ""
}

View File

@@ -0,0 +1,293 @@
package api
import (
"errors"
"testing"
"nofx/crypto"
"nofx/store"
)
// Well-known throwaway development key (hardhat account #1) — never funded.
const testClaw402Key = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"
func withAIWalletBalance(t *testing.T, balance float64, err error) {
t.Helper()
original := queryAIWalletBalance
queryAIWalletBalance = func(string) (float64, error) {
return balance, err
}
t.Cleanup(func() {
queryAIWalletBalance = original
})
}
func findCheck(t *testing.T, checks []LaunchCheck, id string) LaunchCheck {
t.Helper()
for _, check := range checks {
if check.ID == id {
return check
}
}
t.Fatalf("check %q not found in %+v", id, checks)
return LaunchCheck{}
}
func TestCheckLaunchAIModel(t *testing.T) {
if got := checkLaunchAIModel(nil); got.Status != launchCheckStatusFailed || got.Code != "MODEL_NOT_FOUND" {
t.Fatalf("nil model: expected failed/MODEL_NOT_FOUND, got %+v", got)
}
disabled := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: false}
if got := checkLaunchAIModel(disabled); got.Code != "MODEL_DISABLED" {
t.Fatalf("disabled model: expected MODEL_DISABLED, got %+v", got)
}
noKey := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true}
if got := checkLaunchAIModel(noKey); got.Code != "MODEL_MISSING_CREDENTIALS" {
t.Fatalf("missing key: expected MODEL_MISSING_CREDENTIALS, got %+v", got)
}
ready := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true, APIKey: crypto.EncryptedString(testClaw402Key)}
if got := checkLaunchAIModel(ready); got.Status != launchCheckStatusOK {
t.Fatalf("ready model: expected ok, got %+v", got)
}
}
func TestCheckLaunchAIWalletSkipsNonClaw402(t *testing.T) {
model := &store.AIModel{Name: "DeepSeek", Provider: "deepseek", Enabled: true, APIKey: crypto.EncryptedString("sk-test")}
checks := checkLaunchAIWallet(model)
if got := findCheck(t, checks, launchCheckAIWallet); got.Status != launchCheckStatusSkipped {
t.Fatalf("non-claw402 wallet check should be skipped, got %+v", got)
}
if got := findCheck(t, checks, launchCheckAIWalletFunds); got.Status != launchCheckStatusSkipped {
t.Fatalf("non-claw402 funds check should be skipped, got %+v", got)
}
}
func TestCheckLaunchAIWalletInvalidKey(t *testing.T) {
model := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true, APIKey: crypto.EncryptedString("not-a-key")}
checks := checkLaunchAIWallet(model)
got := findCheck(t, checks, launchCheckAIWallet)
if got.Status != launchCheckStatusFailed || got.Code != "AI_WALLET_INVALID_KEY" {
t.Fatalf("invalid key: expected failed/AI_WALLET_INVALID_KEY, got %+v", got)
}
if funds := findCheck(t, checks, launchCheckAIWalletFunds); funds.Status != launchCheckStatusSkipped {
t.Fatalf("funds check should be skipped when the key is invalid, got %+v", funds)
}
}
func TestCheckLaunchAIWalletInsufficientFunds(t *testing.T) {
withAIWalletBalance(t, 0.25, nil)
model := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true, APIKey: crypto.EncryptedString(testClaw402Key)}
checks := checkLaunchAIWallet(model)
wallet := findCheck(t, checks, launchCheckAIWallet)
if wallet.Status != launchCheckStatusOK || wallet.Address == "" {
t.Fatalf("wallet check should pass with derived address, got %+v", wallet)
}
funds := findCheck(t, checks, launchCheckAIWalletFunds)
if funds.Status != launchCheckStatusFailed || funds.Code != "AI_WALLET_INSUFFICIENT_FUNDS" {
t.Fatalf("expected failed/AI_WALLET_INSUFFICIENT_FUNDS, got %+v", funds)
}
if funds.Actual == nil || *funds.Actual != 0.25 {
t.Fatalf("expected actual balance 0.25, got %+v", funds.Actual)
}
if funds.Required != MinAIFeeUSDC {
t.Fatalf("expected required %v, got %v", MinAIFeeUSDC, funds.Required)
}
if funds.Address == "" {
t.Fatalf("funds check should carry the deposit address")
}
}
func TestCheckLaunchAIWalletRPCOutageIsWarningNotFailure(t *testing.T) {
withAIWalletBalance(t, 0, errors.New("rpc unreachable"))
model := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true, APIKey: crypto.EncryptedString(testClaw402Key)}
checks := checkLaunchAIWallet(model)
funds := findCheck(t, checks, launchCheckAIWalletFunds)
if funds.Status != launchCheckStatusWarning || funds.Code != "AI_WALLET_BALANCE_UNKNOWN" {
t.Fatalf("RPC outage must degrade to warning, got %+v", funds)
}
}
func TestCheckLaunchAIWalletFunded(t *testing.T) {
withAIWalletBalance(t, 25.5, nil)
model := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true, APIKey: crypto.EncryptedString(testClaw402Key)}
checks := checkLaunchAIWallet(model)
funds := findCheck(t, checks, launchCheckAIWalletFunds)
if funds.Status != launchCheckStatusOK {
t.Fatalf("funded wallet should pass, got %+v", funds)
}
}
func TestDescribeExchangeConfigIssue(t *testing.T) {
if _, code := describeExchangeConfigIssue(nil); code != "EXCHANGE_NOT_FOUND" {
t.Fatalf("nil exchange: expected EXCHANGE_NOT_FOUND, got %s", code)
}
disabled := &store.Exchange{ID: "ex", ExchangeType: "hyperliquid", Enabled: false}
if _, code := describeExchangeConfigIssue(disabled); code != "EXCHANGE_DISABLED" {
t.Fatalf("disabled: expected EXCHANGE_DISABLED, got %s", code)
}
missing := &store.Exchange{ID: "ex", ExchangeType: "hyperliquid", Enabled: true}
if _, code := describeExchangeConfigIssue(missing); code != "EXCHANGE_MISSING_FIELDS" {
t.Fatalf("missing fields: expected EXCHANGE_MISSING_FIELDS, got %s", code)
}
unapproved := &store.Exchange{
ID: "ex",
ExchangeType: "hyperliquid",
Enabled: true,
APIKey: crypto.EncryptedString(testClaw402Key),
HyperliquidWalletAddr: "0x1111111111111111111111111111111111111111",
}
if _, code := describeExchangeConfigIssue(unapproved); code != "HYPERLIQUID_BUILDER_NOT_APPROVED" {
t.Fatalf("builder unapproved: expected HYPERLIQUID_BUILDER_NOT_APPROVED, got %s", code)
}
unapproved.HyperliquidBuilderApproved = true
if _, code := describeExchangeConfigIssue(unapproved); code != "" {
t.Fatalf("ready exchange: expected no issue, got %s", code)
}
}
func readyHyperliquidExchange() *store.Exchange {
return &store.Exchange{
ID: "ex-hl",
ExchangeType: "hyperliquid",
Enabled: true,
APIKey: crypto.EncryptedString(testClaw402Key),
HyperliquidWalletAddr: "0x1111111111111111111111111111111111111111",
HyperliquidBuilderApproved: true,
}
}
func preflightTestServer(t *testing.T, userID string, states map[string]ExchangeAccountState) *Server {
t.Helper()
server := &Server{exchangeAccountStateCache: NewExchangeAccountStateCache()}
if states != nil {
server.exchangeAccountStateCache.Set(userID, states)
}
return server
}
func TestCheckLaunchExchangeInsufficientFundsBlocks(t *testing.T) {
exchange := readyHyperliquidExchange()
server := preflightTestServer(t, "user-1", map[string]ExchangeAccountState{
exchange.ID: {ExchangeID: exchange.ID, Status: exchangeAccountStatusOK, AvailableBalance: 5.5, TotalEquity: 5.5},
})
checks := server.checkLaunchExchange("user-1", exchange)
funds := findCheck(t, checks, launchCheckExchangeFunds)
if funds.Status != launchCheckStatusFailed || funds.Code != "EXCHANGE_INSUFFICIENT_FUNDS" {
t.Fatalf("expected failed/EXCHANGE_INSUFFICIENT_FUNDS, got %+v", funds)
}
if funds.Actual == nil || *funds.Actual != 5.5 {
t.Fatalf("expected actual 5.5, got %+v", funds.Actual)
}
}
func TestCheckLaunchExchangeDeployedMarginPasses(t *testing.T) {
// A running bot with capital locked in positions: low available balance
// but healthy equity. Restart must not be blocked.
exchange := readyHyperliquidExchange()
server := preflightTestServer(t, "user-1", map[string]ExchangeAccountState{
exchange.ID: {ExchangeID: exchange.ID, Status: exchangeAccountStatusOK, AvailableBalance: 3, TotalEquity: 100},
})
checks := server.checkLaunchExchange("user-1", exchange)
funds := findCheck(t, checks, launchCheckExchangeFunds)
if funds.Status != launchCheckStatusOK {
t.Fatalf("deployed margin with healthy equity should pass, got %+v", funds)
}
if funds.Actual == nil || *funds.Actual != 100 {
t.Fatalf("expected actual 100 (equity), got %+v", funds.Actual)
}
}
func TestCheckLaunchExchangeTestnetLowFundsIsWarning(t *testing.T) {
exchange := readyHyperliquidExchange()
exchange.Testnet = true
server := preflightTestServer(t, "user-1", map[string]ExchangeAccountState{
exchange.ID: {ExchangeID: exchange.ID, Status: exchangeAccountStatusOK, AvailableBalance: 0},
})
checks := server.checkLaunchExchange("user-1", exchange)
funds := findCheck(t, checks, launchCheckExchangeFunds)
if funds.Status != launchCheckStatusWarning {
t.Fatalf("testnet low funds should warn, not block, got %+v", funds)
}
}
func TestCheckLaunchExchangeInvalidCredentials(t *testing.T) {
exchange := readyHyperliquidExchange()
server := preflightTestServer(t, "user-1", map[string]ExchangeAccountState{
exchange.ID: {
ExchangeID: exchange.ID,
Status: exchangeAccountStatusInvalidCredentials,
ErrorCode: "INVALID_CREDENTIALS",
ErrorMessage: "Exchange credentials are invalid",
},
})
checks := server.checkLaunchExchange("user-1", exchange)
account := findCheck(t, checks, launchCheckExchangeAccount)
if account.Status != launchCheckStatusFailed || account.Code != "INVALID_CREDENTIALS" {
t.Fatalf("expected failed/INVALID_CREDENTIALS, got %+v", account)
}
if funds := findCheck(t, checks, launchCheckExchangeFunds); funds.Status != launchCheckStatusSkipped {
t.Fatalf("funds check should be skipped when account probe fails, got %+v", funds)
}
}
func TestRunLaunchPreflightAggregatesReadiness(t *testing.T) {
withAIWalletBalance(t, 10, nil)
exchange := readyHyperliquidExchange()
server := preflightTestServer(t, "user-1", map[string]ExchangeAccountState{
exchange.ID: {ExchangeID: exchange.ID, Status: exchangeAccountStatusOK, AvailableBalance: 100, TotalEquity: 100},
})
model := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true, APIKey: crypto.EncryptedString(testClaw402Key)}
strategy := &store.Strategy{ID: "strat-1", Name: "Autopilot"}
result := server.runLaunchPreflight("user-1", model, exchange, strategy, true)
if !result.Ready {
t.Fatalf("expected ready, got %+v", result)
}
if result.MinAIFeeUSDC != MinAIFeeUSDC || result.MinTradingUSDC != MinTradingUSDC {
t.Fatalf("minimums must be exposed in the response, got %+v", result)
}
// Break one prerequisite → not ready, and Summary explains it.
withAIWalletBalance(t, 0, nil)
result = server.runLaunchPreflight("user-1", model, exchange, strategy, true)
if result.Ready {
t.Fatalf("expected not ready with empty AI wallet, got %+v", result)
}
if result.Summary() == "" {
t.Fatalf("summary should describe the failing check")
}
}
func TestRunLaunchPreflightWarningsDoNotBlock(t *testing.T) {
withAIWalletBalance(t, 0, errors.New("rpc down"))
exchange := readyHyperliquidExchange()
server := preflightTestServer(t, "user-1", map[string]ExchangeAccountState{
exchange.ID: {ExchangeID: exchange.ID, Status: exchangeAccountStatusOK, AvailableBalance: 100},
})
model := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true, APIKey: crypto.EncryptedString(testClaw402Key)}
result := server.runLaunchPreflight("user-1", model, exchange, nil, false)
if !result.Ready {
t.Fatalf("warnings must not block launch, got %+v", result)
}
}

101
api/ratelimit.go Normal file
View File

@@ -0,0 +1,101 @@
package api
import (
"math"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
)
// ipRateLimiter is a small, dependency-free token-bucket rate limiter keyed by
// client IP. It is used to throttle the unauthenticated auth endpoints
// (login / register) against online brute-force attacks.
//
// Design notes:
// - Per-IP token bucket with lazy refill (no background goroutine).
// - Idle buckets are evicted opportunistically so a flood of distinct source
// IPs (e.g. spoofed X-Forwarded-For) cannot grow the map without bound.
// - This is a throttle, not an authenticator. Behind a reverse proxy the
// effective key is whatever gin's ClientIP() resolves; operators who
// terminate TLS at a proxy should configure trusted proxies so ClientIP()
// reflects the real peer rather than a spoofable header.
type ipRateLimiter struct {
mu sync.Mutex
buckets map[string]*rlBucket
rate float64 // tokens added per second
burst float64 // maximum tokens (and initial fill)
lastGC time.Time
}
type rlBucket struct {
tokens float64
last time.Time
}
// newIPRateLimiter creates a limiter that allows bursts up to `burst` requests
// and then refills at `ratePerSec` tokens/second per client IP.
func newIPRateLimiter(ratePerSec, burst float64) *ipRateLimiter {
return &ipRateLimiter{
buckets: make(map[string]*rlBucket),
rate: ratePerSec,
burst: burst,
}
}
// allow reports whether a request from key is permitted at time now, consuming
// one token when it is.
func (l *ipRateLimiter) allow(key string, now time.Time) bool {
l.mu.Lock()
defer l.mu.Unlock()
// Opportunistic GC: drop buckets idle for >10 minutes. Bounds memory even
// under a spoofed-IP flood without needing a background goroutine.
if l.lastGC.IsZero() {
l.lastGC = now
}
if now.Sub(l.lastGC) > time.Minute {
for k, b := range l.buckets {
if now.Sub(b.last) > 10*time.Minute {
delete(l.buckets, k)
}
}
l.lastGC = now
}
b, ok := l.buckets[key]
if !ok {
b = &rlBucket{tokens: l.burst, last: now}
l.buckets[key] = b
}
// Refill based on elapsed time, capped at burst.
elapsed := now.Sub(b.last).Seconds()
if elapsed > 0 {
b.tokens = math.Min(l.burst, b.tokens+elapsed*l.rate)
b.last = now
}
if b.tokens < 1 {
return false
}
b.tokens--
return true
}
// rateLimitMiddleware throttles requests per client IP, returning 429 when the
// caller exceeds the configured rate.
func rateLimitMiddleware(l *ipRateLimiter) gin.HandlerFunc {
return func(c *gin.Context) {
if !l.allow(c.ClientIP(), time.Now()) {
c.Header("Retry-After", "60")
c.JSON(http.StatusTooManyRequests, gin.H{
"error": "Too many requests. Please slow down and try again in a minute.",
})
c.Abort()
return
}
c.Next()
}
}

54
api/ratelimit_test.go Normal file
View File

@@ -0,0 +1,54 @@
package api
import (
"testing"
"time"
)
// TestIPRateLimiterBurstThenThrottle verifies that a client gets `burst`
// immediate attempts and is then throttled until tokens refill.
func TestIPRateLimiterBurstThenThrottle(t *testing.T) {
// 1 token/sec, burst of 3.
l := newIPRateLimiter(1.0, 3)
now := time.Unix(1_700_000_000, 0)
// First 3 requests in the same instant are allowed (the burst).
for i := 0; i < 3; i++ {
if !l.allow("1.2.3.4", now) {
t.Fatalf("request %d in burst should be allowed", i+1)
}
}
// 4th in the same instant is throttled.
if l.allow("1.2.3.4", now) {
t.Fatalf("request beyond burst should be throttled")
}
// After 1 second, one token refills → exactly one more request allowed.
now = now.Add(time.Second)
if !l.allow("1.2.3.4", now) {
t.Fatalf("one token should have refilled after 1s")
}
if l.allow("1.2.3.4", now) {
t.Fatalf("only one token should refill per second")
}
}
// TestIPRateLimiterIsolatesClients verifies one IP exhausting its bucket does
// not throttle a different IP.
func TestIPRateLimiterIsolatesClients(t *testing.T) {
l := newIPRateLimiter(1.0, 2)
now := time.Unix(1_700_000_000, 0)
// Exhaust IP A.
if !l.allow("10.0.0.1", now) || !l.allow("10.0.0.1", now) {
t.Fatalf("IP A burst should be allowed")
}
if l.allow("10.0.0.1", now) {
t.Fatalf("IP A should be throttled after burst")
}
// IP B is unaffected.
if !l.allow("10.0.0.2", now) {
t.Fatalf("IP B should be allowed regardless of IP A")
}
}

View File

@@ -10,6 +10,7 @@ import (
"nofx/logger"
"nofx/manager"
"nofx/store"
"os"
"strings"
"time"
@@ -26,6 +27,7 @@ type Server struct {
httpServer *http.Server
port int
telegramReloadCh chan<- struct{} // signal Telegram bot to reload
authLimiter *ipRateLimiter // per-IP throttle for login/register
}
// NewServer Creates API server
@@ -48,6 +50,10 @@ func NewServer(traderManager *manager.TraderManager, st *store.Store, cryptoServ
cryptoHandler: cryptoHandler,
exchangeAccountStateCache: NewExchangeAccountStateCache(),
port: port,
// Auth throttle: allow a small burst (typos / page reloads) then ~1
// attempt every 6s (10/min) sustained per IP. Generous for a human,
// hostile to online password brute-force.
authLimiter: newIPRateLimiter(1.0/6.0, 8),
}
// Setup routes
@@ -56,24 +62,74 @@ func NewServer(traderManager *manager.TraderManager, st *store.Store, cryptoServ
return s
}
// corsMiddleware CORS middleware
// corsMiddleware returns a CORS handler. Origins come from CORS_ALLOWED_ORIGINS
// (comma-separated). The literal value "*" enables permissive mode — DO NOT use
// in production: the JWT is sent via Authorization header so a wildcard ACAO
// makes stolen tokens replayable from any site.
func corsMiddleware() gin.HandlerFunc {
raw := strings.TrimSpace(os.Getenv("CORS_ALLOWED_ORIGINS"))
allowAny := raw == "*"
var allowlist map[string]struct{}
if !allowAny {
allowlist = make(map[string]struct{})
for _, o := range strings.Split(raw, ",") {
o = strings.TrimSpace(o)
if o == "" {
continue
}
allowlist[o] = struct{}{}
}
if len(allowlist) == 0 {
// Safe defaults for local development.
for _, o := range []string{
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://localhost:5173",
"http://127.0.0.1:5173",
} {
allowlist[o] = struct{}{}
}
logger.Warnf("[CORS] CORS_ALLOWED_ORIGINS not set; defaulting to localhost dev origins only. Set this env var for production.")
}
if allowAny {
logger.Warnf("[CORS] CORS_ALLOWED_ORIGINS=* is INSECURE in production; restrict to your deployment origin(s).")
}
}
return func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
origin := c.GetHeader("Origin")
if origin != "" {
switch {
case allowAny:
c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
c.Writer.Header().Set("Vary", "Origin")
default:
if _, ok := allowlist[origin]; ok {
c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
c.Writer.Header().Set("Vary", "Origin")
}
// Unknown origin: do not set ACAO; the browser will block.
}
}
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
c.Writer.Header().Set("Access-Control-Max-Age", "600")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(http.StatusOK)
return
}
c.Next()
}
}
// setupRoutes Setup routes
func (s *Server) setupRoutes() {
// Ensure the auth throttle exists even when the Server was constructed
// directly (e.g. in tests) rather than via NewServer.
if s.authLimiter == nil {
s.authLimiter = newIPRateLimiter(1.0/6.0, 8)
}
// API route group
api := s.router.Group("/api")
{
@@ -92,11 +148,21 @@ func (s *Server) setupRoutes() {
// Wallet validation (no authentication required — used by frontend config form)
api.POST("/wallet/validate", s.handleWalletValidate)
api.POST("/wallet/generate", s.handleWalletGenerate)
s.route(api, "GET", "/hyperliquid/connect-config", "Get NOFX Hyperliquid builder authorization config", s.handleHyperliquidConnectConfig)
s.route(api, "GET", "/hyperliquid/account", "Get Hyperliquid account balance summary", s.handleHyperliquidAccount)
s.route(api, "GET", "/hyperliquid/agent", "Get Hyperliquid approved agent wallets and authorization expiry", s.handleHyperliquidAgent)
s.route(api, "POST", "/hyperliquid/submit-exchange", "Submit a user-signed Hyperliquid approval action", s.handleHyperliquidSubmitExchange)
// Crypto related endpoints (no authentication required, not exposed to bot)
// Crypto related endpoints (no authentication required, not exposed to bot).
// SECURITY: only the config + public-key endpoints are exposed. Transport
// encryption is one-directional (client encrypts to the server's public key;
// the server decrypts internally on the authenticated config-update handlers).
// A public POST /crypto/decrypt would be a decryption oracle: any
// unauthenticated caller could replay a captured ciphertext and get the
// plaintext (exchange/API credentials) back. It is intentionally NOT
// registered. See crypto_handler.go.
api.GET("/crypto/config", s.cryptoHandler.HandleGetCryptoConfig)
api.GET("/crypto/public-key", s.cryptoHandler.HandleGetPublicKey)
api.POST("/crypto/decrypt", s.cryptoHandler.HandleDecryptSensitiveData)
// Public competition data (no authentication required)
s.route(api, "GET", "/traders", "Public trader list", s.handlePublicTraderList)
@@ -114,11 +180,20 @@ func (s *Server) setupRoutes() {
s.route(api, "GET", "/strategies/public", "Public strategy market", s.handlePublicStrategies)
s.route(api, "POST", "/strategies/estimate-tokens", "Estimate token usage for a strategy config", s.handleEstimateTokens)
// Authentication related routes (no authentication required)
s.route(api, "POST", "/register", "Register new user", s.handleRegister)
s.route(api, "POST", "/login", "User login, returns JWT token", s.handleLogin)
s.route(api, "POST", "/reset-password", "Reset password", s.handleResetPassword)
s.route(api, "POST", "/reset-account", "Clear all users and reset system to allow re-registration", s.handleResetAccount)
// Authentication related routes (no authentication required).
// These are throttled per-IP to blunt online password brute-force; see
// ratelimit.go. Everything else in the public block is read-only or
// idempotent, so the throttle is scoped to the credential endpoints.
authRoutes := api.Group("/", rateLimitMiddleware(s.authLimiter))
s.route(authRoutes, "POST", "/register", "Register new user", s.handleRegister)
s.route(authRoutes, "POST", "/login", "User login, returns JWT token", s.handleLogin)
// SECURITY: password/account recovery is NOT exposed over HTTP. An
// unauthenticated recovery endpoint is a remote auth-bypass on any
// public-facing deployment (the confirm phrase is in the frontend and
// returned by the API, so it is friction, not authentication). Recovery
// is now a local CLI run on the host — `nofx reset-password` /
// `nofx reset-account` — which requires shell access the attacker lacks.
// See cli.go.
// Routes requiring authentication
protected := api.Group("/", s.authMiddleware())
@@ -127,9 +202,6 @@ func (s *Server) setupRoutes() {
s.route(protected, "POST", "/logout", "Logout (blacklist token)", s.handleLogout)
s.route(protected, "POST", "/onboarding/beginner", "Prepare beginner claw402 wallet and default model", s.handleBeginnerOnboarding)
s.route(protected, "GET", "/onboarding/beginner/current", "Get current beginner claw402 wallet", s.handleCurrentBeginnerWallet)
s.route(protected, "GET", "/agent/preferences", "Get persistent agent preferences", s.handleGetAgentPreferences)
s.route(protected, "POST", "/agent/preferences", "Create persistent agent preference", s.handleCreateAgentPreference)
s.route(protected, "DELETE", "/agent/preferences/:id", "Delete persistent agent preference", s.handleDeleteAgentPreference)
// User account management
s.routeWithSchema(protected, "PUT", "/user/password", "Change current user password",
@@ -139,6 +211,11 @@ func (s *Server) setupRoutes() {
// Server IP query (requires authentication, for whitelist configuration)
s.route(protected, "GET", "/server-ip", "Get server public IP (for exchange whitelist)", s.handleGetServerIP)
s.route(protected, "GET", "/vergex/signal-ranking", "Vergex signal ranking via claw402 (?marketType=all&limit=30)", s.handleVergexSignalRanking)
s.route(protected, "GET", "/vergex/signal-lab", "Vergex signal lab via claw402 (?marketType=hip3_perp&symbol=AAPL)", s.handleVergexSignalLab)
s.route(protected, "GET", "/vergex/cost-liquidation-heatmap", "Vergex cost/liquidation heatmap via claw402 (?marketType=hip3_perp&symbol=AAPL)", s.handleVergexCostLiquidationHeatmap)
s.route(protected, "GET", "/vergex/flow-markets", "Vergex net-flow market ranking via claw402 (?chain=mainnet&window=1h&limit=25)", s.handleVergexFlowMarkets)
// AI trader management
s.routeWithSchema(protected, "GET", "/my-traders", "List user's traders with status",
`Returns: [{"trader_id":"<EXACT id — use this as trader_id in all ?trader_id= queries and POST /traders/:id/start|stop>","trader_name":"<string>","is_running":<bool>}]
@@ -148,7 +225,7 @@ NOTE: The id field is "trader_id" (NOT "id"). Always read trader_id from this en
`:id = trader_id from GET /api/my-traders`,
s.handleGetTraderConfig)
s.routeWithSchema(protected, "POST", "/traders", "Create a new AI trader",
`Body: {"name":"<string, required>","ai_model_id":"<EXACT id field from GET /api/models — e.g. 'abc123_deepseek', NOT the provider name 'deepseek'>","exchange_id":"<EXACT id field from GET /api/exchanges — e.g. '05785d3b-841e-...', NOT the type name>","strategy_id":"<EXACT id field from GET /api/strategies>","scan_interval_minutes":<int, default 3, minimum 3>}
`Body: {"name":"<string, required>","ai_model_id":"<EXACT id field from GET /api/models — e.g. 'abc123_deepseek', NOT the provider name 'deepseek'>","exchange_id":"<EXACT id field from GET /api/exchanges — e.g. '05785d3b-841e-...', NOT the type name>","strategy_id":"<EXACT id field from GET /api/strategies>","scan_interval_minutes":<int, default 15, minimum 3>}
IMPORTANT: ai_model_id and exchange_id must be the full "id" value from the Account State, not the provider/type name.`,
s.handleCreateTrader)
s.routeWithSchema(protected, "PUT", "/traders/:id", "Update trader configuration",
@@ -160,8 +237,17 @@ Only include fields you want to change.`,
`:id = trader_id from GET /api/my-traders. Stops and permanently removes the trader and all its data.`,
s.handleDeleteTrader)
s.routeWithSchema(protected, "POST", "/traders/:id/start", "Start trader — begins live trading",
`:id = trader_id from GET /api/my-traders. No request body needed. The trader must have a valid exchange and AI model configured.`,
`:id = trader_id from GET /api/my-traders. No request body needed. The trader must have a valid exchange and AI model configured.
Runs launch preflight first and returns 400 with {"error_key":"trader.start.preflight_failed","preflight":{...}} when checks fail. Append ?force=true to skip balance gates.`,
s.handleStartTrader)
s.routeWithSchema(protected, "GET", "/traders/:id/preflight", "Run launch readiness checks for a trader",
`:id = trader_id from GET /api/my-traders.
Returns: {"ready":<bool>,"checks":[{"id":"ai_model|ai_wallet|ai_wallet_funds|strategy|exchange_config|exchange_account|exchange_funds","status":"ok|failed|warning|skipped","code":"<string>","message":"<string>","required":<number>,"actual":<number>,"asset":"<string>","address":"<string>"}],"min_ai_fee_usdc":<number>,"min_trading_usdc":<number>}`,
s.handleTraderPreflight)
s.routeWithSchema(protected, "POST", "/launch/preflight", "Run launch readiness checks before creating a trader",
`Body: {"ai_model_id":"<EXACT id from GET /api/models>","exchange_id":"<EXACT id from GET /api/exchanges>","strategy_id":"<optional, EXACT id from GET /api/strategies>"}
Returns the same shape as GET /api/traders/:id/preflight. Use this before POST /api/traders to surface every missing prerequisite at once.`,
s.handleLaunchPreflight)
s.routeWithSchema(protected, "POST", "/traders/:id/stop", "Stop trader — halts live trading",
`:id = trader_id from GET /api/my-traders. No request body needed. Gracefully stops the trading loop.`,
s.handleStopTrader)
@@ -259,10 +345,10 @@ CRITICAL: Always use the "id" field for strategy_id.`,
IMPORTANT: For most use cases just POST {"name":"<name>"} — the backend fills everything in. Only include "config" when the user explicitly requests custom settings (specific coins, custom leverage, custom timeframes).
StrategyConfig fields:
coin_source.source_type: "static"(fixed coin list) | "ai500"(AI top500 ranking) | "oi_top"(OI increasing, suited for long) | "oi_low"(OI decreasing, suited for short) | "mixed"
coin_source.static_coins: ["BTCUSDT","ETHUSDT"] — only when source_type="static"
coin_source.use_ai500, ai500_limit: number of coins from AI500 pool (default 10)
coin_source.use_oi_top/use_oi_low, oi_top_limit/oi_low_limit: OI-based coin selection
coin_source.source_type: "vergex_signal" (Claw402/Vergex signal-ranking; default and recommended)
coin_source.vergex_limit: number of Claw402 candidates enriched with detail data (default 10, max 10)
coin_source.vergex_market_type: "all" for the full Claw402 board; detail calls use each ranking item's market_type
coin_source.vergex_chain: "hyperliquid"
indicators.klines.primary_timeframe: "1m"|"3m"|"5m"|"15m"|"1h"|"4h" — scalping→"5m", trend/swing→"1h"/"4h"
indicators.klines.primary_count: number of candles (20-100)
indicators.klines.enable_multi_timeframe: true for trend/swing analysis
@@ -359,6 +445,10 @@ Returns the most recent AI decision for each symbol analyzed in the last scan cy
`Query: ?trader_id=<EXACT trader_id from GET /api/my-traders>
Returns: {"total_trades":<int>,"winning_trades":<int>,"win_rate":<float>,"total_pnl":<float>,"sharpe_ratio":<float>,"max_drawdown":<float>}`,
s.handleStatistics)
s.routeWithSchema(protected, "GET", "/statistics/full", "Full trade-quality metrics (win rate, profit factor, Sharpe, max drawdown)",
`Query: ?trader_id=<EXACT trader_id from GET /api/my-traders>
Returns: {"total_trades","win_trades","loss_trades","win_rate","profit_factor","sharpe_ratio","total_pnl","total_fee","avg_win","avg_loss","max_drawdown_pct"}`,
s.handleStatisticsFull)
}
}
@@ -511,35 +601,46 @@ func isPrivateIP(ip net.IP) bool {
return false
}
// getTraderFromQuery Get trader from query parameter
// getTraderFromQuery resolves a trader from the ?trader_id= query parameter,
// strictly scoped to the authenticated caller.
//
// Ownership is always enforced against the caller's own trader list in the
// store. We deliberately never fall back to the global in-memory trader map
// (TraderManager holds every account's traders): returning an entry from it for
// a trader the caller does not own is a cross-tenant data leak (IDOR) — a
// freshly-registered user with no traders of their own could otherwise pass any
// other account's trader_id and read its balance, positions and AI decisions.
func (s *Server) getTraderFromQuery(c *gin.Context) (*manager.TraderManager, string, error) {
userID := c.GetString("user_id")
traderID := c.Query("trader_id")
// Ensure user's traders are loaded into memory
err := s.traderManager.LoadUserTradersFromStore(s.store, userID)
if err != nil {
// Ensure user's traders are loaded into memory.
if err := s.traderManager.LoadUserTradersFromStore(s.store, userID); err != nil {
logger.Infof("⚠️ Failed to load traders for user %s: %v", userID, err)
}
if traderID == "" {
// If no trader_id specified, return first trader for this user
ids := s.traderManager.GetTraderIDs()
if len(ids) == 0 {
// Resolve strictly from the caller's own trader list.
userTraders, err := s.store.Trader().List(userID)
if err != nil {
return nil, "", fmt.Errorf("failed to load traders for this account: %w", err)
}
if len(userTraders) == 0 {
return nil, "", fmt.Errorf("No available traders")
}
// Get user's trader list, prioritize returning user's own traders
userTraders, err := s.store.Trader().List(userID)
if err == nil && len(userTraders) > 0 {
traderID = userTraders[0].ID
} else {
traderID = ids[0]
}
if traderID == "" {
// No trader_id specified — default to the caller's first trader.
return s.traderManager, userTraders[0].ID, nil
}
// A trader_id was supplied — it must belong to the caller.
for _, t := range userTraders {
if t.ID == traderID {
return s.traderManager, traderID, nil
}
}
return nil, "", fmt.Errorf("trader not found for this account")
}
// authMiddleware JWT authentication middleware
func (s *Server) authMiddleware() gin.HandlerFunc {

View File

@@ -2,11 +2,38 @@ package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"nofx/store"
"github.com/gin-gonic/gin"
)
// TestPublicDecryptRouteNotRegistered is a security regression test: the
// unauthenticated POST /api/crypto/decrypt route was a decryption oracle and
// must never be re-registered. A built server's router must not route to it.
func TestPublicDecryptRouteNotRegistered(t *testing.T) {
gin.SetMode(gin.TestMode)
s := &Server{router: gin.New()}
s.setupRoutes()
for _, r := range s.router.Routes() {
if r.Method == http.MethodPost && r.Path == "/api/crypto/decrypt" {
t.Fatalf("SECURITY REGRESSION: public decryption oracle POST /api/crypto/decrypt is registered")
}
}
// Also assert at the HTTP layer that the route is not handled.
req := httptest.NewRequest(http.MethodPost, "/api/crypto/decrypt", nil)
w := httptest.NewRecorder()
s.router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404 for POST /api/crypto/decrypt, got %d", w.Code)
}
}
// TestUpdateTraderRequest_SystemPromptTemplate Test whether SystemPromptTemplate field exists when updating trader
func TestUpdateTraderRequest_SystemPromptTemplate(t *testing.T) {
tests := []struct {

View File

@@ -1,6 +1,7 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
@@ -20,6 +21,9 @@ import (
// validateStrategyConfig validates strategy configuration and returns warnings
func validateStrategyConfig(config *store.StrategyConfig) []string {
var warnings []string
if config.StrategyType == "grid_trading" {
return warnings
}
// Validate NofxOS API key if any NofxOS feature is enabled
if (config.Indicators.EnableQuantData || config.Indicators.EnableOIRanking ||
@@ -31,6 +35,17 @@ func validateStrategyConfig(config *store.StrategyConfig) []string {
return warnings
}
func attachPublishConfig(config *store.StrategyConfig, strategy *store.Strategy) {
if config == nil || strategy == nil {
return
}
config.ClampLimits()
config.PublishConfig = &store.PublishStrategyConfig{
IsPublic: strategy.IsPublic,
ConfigVisible: strategy.ConfigVisible,
}
}
// handleEstimateTokens estimates token usage for a strategy config (no auth required, pure computation)
func (s *Server) handleEstimateTokens(c *gin.Context) {
var req struct {
@@ -71,6 +86,7 @@ func (s *Server) handlePublicStrategies(c *gin.Context) {
if st.ConfigVisible {
var config store.StrategyConfig
json.Unmarshal([]byte(st.Config), &config)
attachPublishConfig(&config, st)
item["config"] = config
}
@@ -90,6 +106,14 @@ func (s *Server) handleGetStrategies(c *gin.Context) {
return
}
lang := c.Query("lang")
if lang == "" {
lang = "zh"
}
if err := s.createDefaultStrategies(userID, lang); err != nil {
logger.Warnf("Failed to sync default strategy presets for user %s: %v", userID, err)
}
strategies, err := s.store.Strategy().List(userID)
if err != nil {
SafeInternalError(c, "Failed to get strategy list", err)
@@ -101,6 +125,7 @@ func (s *Server) handleGetStrategies(c *gin.Context) {
for _, st := range strategies {
var config store.StrategyConfig
json.Unmarshal([]byte(st.Config), &config)
attachPublishConfig(&config, st)
result = append(result, gin.H{
"id": st.ID,
@@ -139,6 +164,7 @@ func (s *Server) handleGetStrategy(c *gin.Context) {
var config store.StrategyConfig
json.Unmarshal([]byte(strategy.Config), &config)
attachPublishConfig(&config, strategy)
c.JSON(http.StatusOK, gin.H{
"id": strategy.ID,
@@ -166,6 +192,8 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
Description string `json:"description"`
Lang string `json:"lang"` // "zh" or "en", used when config is omitted
Config *store.StrategyConfig `json:"config"` // optional — uses default if omitted
IsPublic bool `json:"is_public"`
ConfigVisible bool `json:"config_visible"`
}
if err := c.ShouldBindJSON(&req); err != nil {
@@ -182,6 +210,19 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
defaultCfg := store.GetDefaultStrategyConfig(lang)
req.Config = &defaultCfg
}
beforeClamp := *req.Config
req.Config.ClampLimits()
hadPublishConfig := req.Config.PublishConfig != nil
isPublic := req.IsPublic
configVisible := req.ConfigVisible
if hadPublishConfig {
isPublic = req.Config.PublishConfig.IsPublic
configVisible = req.Config.PublishConfig.ConfigVisible
}
req.Config.PublishConfig = &store.PublishStrategyConfig{
IsPublic: isPublic,
ConfigVisible: configVisible,
}
// Serialize configuration
configJSON, err := json.Marshal(req.Config)
@@ -197,6 +238,9 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
Description: req.Description,
IsActive: false,
IsDefault: false,
IsPublic: isPublic,
// Existing default is true; keep that behavior when no explicit publish config is sent.
ConfigVisible: configVisible || !hadPublishConfig,
Config: string(configJSON),
}
@@ -207,6 +251,7 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
// Validate configuration and collect warnings
warnings := validateStrategyConfig(req.Config)
warnings = append(warnings, store.StrategyClampWarnings(beforeClamp, *req.Config, req.Config.Language)...)
response := gin.H{
"id": strategy.ID,
@@ -263,14 +308,21 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
mergedConfig = store.StrategyConfig{}
}
// Apply incoming config on top: top-level sections present in the request overwrite
// their corresponding existing section; absent sections remain unchanged.
// Apply incoming config on top while preserving nested fields that were not sent.
if len(req.Config) > 0 && string(req.Config) != "null" {
if err := json.Unmarshal(req.Config, &mergedConfig); err != nil {
var patch map[string]any
if err := json.Unmarshal(req.Config, &patch); err != nil {
SafeBadRequest(c, "Invalid config JSON")
return
}
mergedConfig, err = store.MergeStrategyConfig(mergedConfig, patch)
if err != nil {
SafeBadRequest(c, "Invalid config JSON")
return
}
}
beforeClamp := mergedConfig
mergedConfig.ClampLimits()
// Preserve existing name/description when not supplied
name := req.Name
@@ -324,6 +376,7 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
// Validate merged configuration and collect warnings
warnings := validateStrategyConfig(&mergedConfig)
warnings = append(warnings, store.StrategyClampWarnings(beforeClamp, mergedConfig, mergedConfig.Language)...)
response := gin.H{"message": "Strategy updated successfully"}
if len(warnings) > 0 {
@@ -417,6 +470,7 @@ func (s *Server) handleGetActiveStrategy(c *gin.Context) {
var config store.StrategyConfig
json.Unmarshal([]byte(strategy.Config), &config)
attachPublishConfig(&config, strategy)
c.JSON(http.StatusOK, gin.H{
"id": strategy.ID,
@@ -583,6 +637,7 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) {
symbols = append(symbols, c.Symbol)
}
quantDataMap := engine.FetchQuantDataBatch(symbols)
vergexDataMap := engine.FetchVergexDataBatch(context.Background(), symbols)
// Fetch OI ranking data (market-wide position changes)
oiRankingData := engine.FetchOIRankingData()
@@ -613,6 +668,7 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) {
PromptVariant: req.PromptVariant,
MarketDataMap: marketDataMap,
QuantDataMap: quantDataMap,
VergexDataMap: vergexDataMap,
OIRankingData: oiRankingData,
NetFlowRankingData: netFlowRankingData,
PriceRankingData: priceRankingData,

View File

@@ -15,13 +15,10 @@ func MaskSensitiveString(s string) string {
return s[:4] + "****" + s[length-4:]
}
// SanitizeModelConfigForLog Sanitize model configuration for log output
func SanitizeModelConfigForLog(models map[string]struct {
Enabled bool `json:"enabled"`
APIKey string `json:"api_key"`
CustomAPIURL string `json:"custom_api_url"`
CustomModelName string `json:"custom_model_name"`
}) map[string]interface{} {
// SanitizeModelConfigForLog Sanitize model configuration for log output.
// Takes the same ModelConfigUpdate type used by the request handler so the two
// can never drift out of sync.
func SanitizeModelConfigForLog(models map[string]ModelConfigUpdate) map[string]interface{} {
safe := make(map[string]interface{})
for modelID, cfg := range models {
safe[modelID] = map[string]interface{}{
@@ -34,19 +31,12 @@ func SanitizeModelConfigForLog(models map[string]struct {
return safe
}
// SanitizeExchangeConfigForLog Sanitize exchange configuration for log output
func SanitizeExchangeConfigForLog(exchanges map[string]struct {
Enabled bool `json:"enabled"`
APIKey string `json:"api_key"`
SecretKey string `json:"secret_key"`
Testnet bool `json:"testnet"`
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
AsterUser string `json:"aster_user"`
AsterSigner string `json:"aster_signer"`
AsterPrivateKey string `json:"aster_private_key"`
LighterWalletAddr string `json:"lighter_wallet_addr"`
LighterPrivateKey string `json:"lighter_private_key"`
}) map[string]interface{} {
// SanitizeExchangeConfigForLog Sanitize exchange configuration for log output.
// Takes the same ExchangeConfigUpdate type used by the request handler so every
// sensitive field is guaranteed to be masked — adding a field to the request
// type without masking it here would not compile around this helper, but more
// importantly keeps the masking exhaustive.
func SanitizeExchangeConfigForLog(exchanges map[string]ExchangeConfigUpdate) map[string]interface{} {
safe := make(map[string]interface{})
for exchangeID, cfg := range exchanges {
safeExchange := map[string]interface{}{
@@ -61,12 +51,18 @@ func SanitizeExchangeConfigForLog(exchanges map[string]struct {
if cfg.SecretKey != "" {
safeExchange["secret_key"] = MaskSensitiveString(cfg.SecretKey)
}
if cfg.Passphrase != "" {
safeExchange["passphrase"] = MaskSensitiveString(cfg.Passphrase)
}
if cfg.AsterPrivateKey != "" {
safeExchange["aster_private_key"] = MaskSensitiveString(cfg.AsterPrivateKey)
}
if cfg.LighterPrivateKey != "" {
safeExchange["lighter_private_key"] = MaskSensitiveString(cfg.LighterPrivateKey)
}
if cfg.LighterAPIKeyPrivateKey != "" {
safeExchange["lighter_api_key_private_key"] = MaskSensitiveString(cfg.LighterAPIKeyPrivateKey)
}
// Add non-sensitive fields directly
if cfg.HyperliquidWalletAddr != "" {

View File

@@ -1,6 +1,8 @@
package api
import (
"fmt"
"strings"
"testing"
)
@@ -48,12 +50,7 @@ func TestMaskSensitiveString(t *testing.T) {
}
func TestSanitizeModelConfigForLog(t *testing.T) {
models := map[string]struct {
Enabled bool `json:"enabled"`
APIKey string `json:"api_key"`
CustomAPIURL string `json:"custom_api_url"`
CustomModelName string `json:"custom_model_name"`
}{
models := map[string]ModelConfigUpdate{
"deepseek": {
Enabled: true,
APIKey: "sk-1234567890abcdefghijklmnopqrstuvwxyz",
@@ -88,32 +85,29 @@ func TestSanitizeModelConfigForLog(t *testing.T) {
}
func TestSanitizeExchangeConfigForLog(t *testing.T) {
exchanges := map[string]struct {
Enabled bool `json:"enabled"`
APIKey string `json:"api_key"`
SecretKey string `json:"secret_key"`
Testnet bool `json:"testnet"`
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
AsterUser string `json:"aster_user"`
AsterSigner string `json:"aster_signer"`
AsterPrivateKey string `json:"aster_private_key"`
LighterWalletAddr string `json:"lighter_wallet_addr"`
LighterPrivateKey string `json:"lighter_private_key"`
}{
exchanges := map[string]ExchangeConfigUpdate{
"binance": {
Enabled: true,
APIKey: "binance_api_key_1234567890abcdef",
SecretKey: "binance_secret_key_1234567890abcdef",
Testnet: false,
LighterWalletAddr: "",
LighterPrivateKey: "",
},
"okx": {
Enabled: true,
APIKey: "okx_api_key_1234567890abcdef",
SecretKey: "okx_secret_key_1234567890abcdef",
Passphrase: "okx_passphrase_supersecret_value",
},
"lighter": {
Enabled: true,
LighterWalletAddr: "0xabcdef0000000000000000000000000000000000",
LighterPrivateKey: "lighter_private_key_1234567890abcdef",
LighterAPIKeyPrivateKey: "lighter_api_key_private_key_1234567890abcdef",
},
"hyperliquid": {
Enabled: true,
HyperliquidWalletAddr: "0x1234567890abcdef1234567890abcdef12345678",
Testnet: false,
LighterWalletAddr: "",
LighterPrivateKey: "",
},
}
@@ -143,6 +137,32 @@ func TestSanitizeExchangeConfigForLog(t *testing.T) {
t.Errorf("expected masked secret_key='bina****cdef', got %q", maskedSecretKey)
}
// Check OKX passphrase is masked (regression: previously not covered)
okxConfig, ok := result["okx"].(map[string]interface{})
if !ok {
t.Fatal("okx config not found or wrong type")
}
maskedPassphrase, ok := okxConfig["passphrase"].(string)
if !ok {
t.Fatal("okx passphrase not found or wrong type")
}
if maskedPassphrase != "okx_****alue" {
t.Errorf("expected masked passphrase='okx_****alue', got %q", maskedPassphrase)
}
// Check Lighter API key private key is masked (regression: previously not covered)
lighterConfig, ok := result["lighter"].(map[string]interface{})
if !ok {
t.Fatal("lighter config not found or wrong type")
}
maskedLighterAPIKey, ok := lighterConfig["lighter_api_key_private_key"].(string)
if !ok {
t.Fatal("lighter_api_key_private_key not found or wrong type")
}
if maskedLighterAPIKey != "ligh****cdef" {
t.Errorf("expected masked lighter_api_key_private_key='ligh****cdef', got %q", maskedLighterAPIKey)
}
// Check Hyperliquid configuration
hlConfig, ok := result["hyperliquid"].(map[string]interface{})
if !ok {
@@ -160,6 +180,41 @@ func TestSanitizeExchangeConfigForLog(t *testing.T) {
}
}
// TestSanitizeExchangeConfigForLog_NoPlaintextSecrets renders the sanitized log
// output exactly as the handler does (`%+v`) and asserts that no plaintext
// secret — including the passphrase and lighter API key private key that were
// historically not redacted — survives into the log line.
func TestSanitizeExchangeConfigForLog_NoPlaintextSecrets(t *testing.T) {
secrets := map[string]string{
"api_key": "binance_api_key_1234567890abcdef",
"secret_key": "binance_secret_key_1234567890abcdef",
"passphrase": "okx_passphrase_supersecret_value",
"aster_private_key": "aster_private_key_1234567890abcdef",
"lighter_private_key": "lighter_private_key_1234567890abcdef",
"lighter_api_key_private_key": "lighter_api_key_private_key_1234567890abcdef",
}
exchanges := map[string]ExchangeConfigUpdate{
"okx": {
Enabled: true,
APIKey: secrets["api_key"],
SecretKey: secrets["secret_key"],
Passphrase: secrets["passphrase"],
AsterPrivateKey: secrets["aster_private_key"],
LighterPrivateKey: secrets["lighter_private_key"],
LighterAPIKeyPrivateKey: secrets["lighter_api_key_private_key"],
},
}
rendered := fmt.Sprintf("%+v", SanitizeExchangeConfigForLog(exchanges))
for field, secret := range secrets {
if strings.Contains(rendered, secret) {
t.Errorf("sanitized log leaked plaintext %s: %q present in %q", field, secret, rendered)
}
}
}
func TestMaskEmail(t *testing.T) {
tests := []struct {
name string

228
cli.go Normal file
View File

@@ -0,0 +1,228 @@
package main
import (
"bufio"
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"nofx/auth"
"nofx/config"
"nofx/crypto"
"nofx/logger"
"nofx/store"
"github.com/joho/godotenv"
"golang.org/x/term"
"gorm.io/gorm"
)
// minResetPasswordLen mirrors the minimum enforced on the authenticated
// password-change path (PUT /api/user/password).
const minResetPasswordLen = 8
// runCLISubcommand dispatches local admin subcommands.
//
// SECURITY: account recovery (reset-password, reset-account) is intentionally
// NOT exposed over HTTP. Performing it requires running this binary on the host,
// which in turn requires shell/file access to the server. A remote attacker on a
// public-facing deployment has only the network — they can reach the API but not
// a local process — so recovery cannot be triggered remotely. This is what makes
// the recovery path safe even when NOFX is deployed on the public internet.
//
// Returns true if a subcommand was recognized and handled (caller should exit).
// Unknown first args fall through to false to preserve the historical behavior
// where `nofx <dbpath>` overrides the SQLite path.
func runCLISubcommand(args []string) bool {
if len(args) == 0 {
return false
}
switch args[0] {
case "reset-password":
runResetPassword(args[1:])
return true
case "reset-account":
runResetAccount(args[1:])
return true
default:
return false
}
}
// openStoreForCLI loads config + encryption and opens the same database the
// server uses, so subcommands operate on the live data.
func openStoreForCLI(dbPathOverride string) (*store.Store, error) {
_ = godotenv.Load()
logger.Init(nil)
config.MustInit()
cfg := config.Get()
if strings.TrimSpace(dbPathOverride) != "" {
cfg.DBPath = dbPathOverride
}
cryptoService, err := crypto.NewCryptoService()
if err != nil {
return nil, fmt.Errorf("initialize encryption service: %w", err)
}
crypto.SetGlobalCryptoService(cryptoService)
if cfg.DBType == "sqlite" {
if dir := filepath.Dir(cfg.DBPath); dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("create data directory: %w", err)
}
}
}
dbType := store.DBTypeSQLite
if cfg.DBType == "postgres" {
dbType = store.DBTypePostgres
}
return store.NewWithConfig(store.DBConfig{
Type: dbType,
Path: cfg.DBPath,
Host: cfg.DBHost,
Port: cfg.DBPort,
User: cfg.DBUser,
Password: cfg.DBPassword,
DBName: cfg.DBName,
SSLMode: cfg.DBSSLMode,
})
}
// runResetPassword resets the password for a single account from the command
// line. Usage: `nofx reset-password --email you@example.com`.
func runResetPassword(args []string) {
fs := flag.NewFlagSet("reset-password", flag.ExitOnError)
email := fs.String("email", "", "email of the account to reset (required)")
password := fs.String("password", "", "new password (min 8 chars); omit to enter it interactively")
dbPath := fs.String("db", "", "override SQLite DB path (defaults to config / DB_PATH)")
_ = fs.Parse(args)
if strings.TrimSpace(*email) == "" {
fmt.Fprintln(os.Stderr, "error: --email is required")
fmt.Fprintln(os.Stderr, "usage: nofx reset-password --email you@example.com")
os.Exit(2)
}
st, err := openStoreForCLI(*dbPath)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
defer st.Close()
user, err := st.User().GetByEmail(strings.TrimSpace(*email))
if err != nil {
fmt.Fprintf(os.Stderr, "error: no account found for %q\n", strings.TrimSpace(*email))
os.Exit(1)
}
newPassword, err := resolveNewPassword(*password)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
hash, err := auth.HashPassword(newPassword)
if err != nil {
fmt.Fprintf(os.Stderr, "error: failed to hash password: %v\n", err)
os.Exit(1)
}
if err := st.User().UpdatePassword(user.ID, hash); err != nil {
fmt.Fprintf(os.Stderr, "error: failed to update password: %v\n", err)
os.Exit(1)
}
fmt.Printf("✓ Password reset for %s. Log in with the new password.\n", user.Email)
}
// runResetAccount wipes the database back to an uninitialized state. This is the
// destructive "forgot everything" recovery, moved off the public API.
func runResetAccount(args []string) {
fs := flag.NewFlagSet("reset-account", flag.ExitOnError)
dbPath := fs.String("db", "", "override SQLite DB path (defaults to config / DB_PATH)")
yes := fs.Bool("yes", false, "skip the interactive confirmation prompt")
_ = fs.Parse(args)
if !*yes {
fmt.Print("This permanently deletes ALL users, traders, strategies, AI models and\n" +
"exchanges — including wallet keys and exchange credentials.\n" +
"Type 'wipe' to confirm: ")
line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
if strings.TrimSpace(line) != "wipe" {
fmt.Fprintln(os.Stderr, "aborted")
os.Exit(1)
}
}
st, err := openStoreForCLI(*dbPath)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
defer st.Close()
err = st.Transaction(func(tx *gorm.DB) error {
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Trader{})
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Strategy{})
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.AIModel{})
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Exchange{})
if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.User{}).Error; err != nil {
return fmt.Errorf("failed to delete users: %w", err)
}
return nil
})
if err != nil {
fmt.Fprintf(os.Stderr, "error: failed to reset account: %v\n", err)
os.Exit(1)
}
fmt.Println("✓ System wiped. Register a fresh account and re-import everything.")
}
// resolveNewPassword returns the new password from the --password flag, or
// prompts for it (hidden) on a TTY, or reads a single line from piped stdin.
func resolveNewPassword(flagValue string) (string, error) {
if flagValue != "" {
if len(flagValue) < minResetPasswordLen {
return "", fmt.Errorf("password must be at least %d characters", minResetPasswordLen)
}
return flagValue, nil
}
if term.IsTerminal(int(os.Stdin.Fd())) {
fmt.Printf("New password (min %d chars): ", minResetPasswordLen)
first, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println()
if err != nil {
return "", fmt.Errorf("failed to read password: %w", err)
}
fmt.Print("Confirm new password: ")
second, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println()
if err != nil {
return "", fmt.Errorf("failed to read password: %w", err)
}
if string(first) != string(second) {
return "", errors.New("passwords do not match")
}
if len(first) < minResetPasswordLen {
return "", fmt.Errorf("password must be at least %d characters", minResetPasswordLen)
}
return string(first), nil
}
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
password := strings.TrimRight(line, "\r\n")
if password == "" {
return "", fmt.Errorf("no password provided on stdin: %w", err)
}
if len(password) < minResetPasswordLen {
return "", fmt.Errorf("password must be at least %d characters", minResetPasswordLen)
}
return password, nil
}

120
cmd/e2e_builder_fee/main.go Normal file
View File

@@ -0,0 +1,120 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"nofx/config"
nofxcrypto "nofx/crypto"
"nofx/store"
hltrader "nofx/trader/hyperliquid"
"os"
"strconv"
"strings"
"time"
"github.com/joho/godotenv"
)
type clearinghouseState struct {
CrossMarginSummary struct {
AccountValue string `json:"accountValue"`
} `json:"crossMarginSummary"`
Withdrawable string `json:"withdrawable"`
AssetPositions []struct {
Position struct {
Coin string `json:"coin"`
Szi string `json:"szi"`
EntryPx string `json:"entryPx"`
PositionValue string `json:"positionValue"`
} `json:"position"`
} `json:"assetPositions"`
}
func fetchState(wallet string) (*clearinghouseState, error) {
body := strings.NewReader(fmt.Sprintf(`{"type":"clearinghouseState","user":%q}`, wallet))
resp, err := http.Post("https://api.hyperliquid.xyz/info", "application/json", body)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var state clearinghouseState
if err := json.NewDecoder(resp.Body).Decode(&state); err != nil {
return nil, err
}
return &state, nil
}
func positionSize(state *clearinghouseState, coin string) float64 {
for _, ap := range state.AssetPositions {
if strings.EqualFold(ap.Position.Coin, coin) {
v, _ := strconv.ParseFloat(ap.Position.Szi, 64)
return v
}
}
return 0
}
func main() {
_ = godotenv.Load()
config.Init()
cryptoService, err := nofxcrypto.NewCryptoService()
if err != nil {
panic(err)
}
nofxcrypto.SetGlobalCryptoService(cryptoService)
cfg := config.Get()
st, err := store.NewWithConfig(store.DBConfig{Type: store.DBTypeSQLite, Path: cfg.DBPath})
if err != nil {
panic(err)
}
defer st.Close()
var ex store.Exchange
if err := st.GormDB().Where("exchange_type = ? AND enabled = ? AND hyperliquid_wallet_addr <> ''", "hyperliquid", true).First(&ex).Error; err != nil {
panic(fmt.Errorf("no enabled Hyperliquid exchange with wallet/private key found: %w", err))
}
if strings.TrimSpace(string(ex.APIKey)) == "" {
panic("Hyperliquid exchange has empty decrypted agent private key")
}
fmt.Printf("E2E exchange=%s account=%s wallet=%s testnet=%v builderApprovedFlag=%v\n", ex.ID, ex.AccountName, ex.HyperliquidWalletAddr, ex.Testnet, ex.HyperliquidBuilderApproved)
before, err := fetchState(ex.HyperliquidWalletAddr)
if err != nil {
panic(err)
}
fmt.Printf("BEFORE accountValue=%s withdrawable=%s HOOD_szi=%.6f\n", before.CrossMarginSummary.AccountValue, before.Withdrawable, positionSize(before, "xyz:HOOD"))
tr, err := hltrader.NewHyperliquidTrader(string(ex.APIKey), ex.HyperliquidWalletAddr, false, ex.HyperliquidUnifiedAcct)
if err != nil {
panic(err)
}
const symbol = "HOOD-USDC"
const qty = 0.15
fmt.Printf("OPEN_LONG symbol=%s qty=%.3f builderRequired=true\n", symbol, qty)
if _, err := tr.OpenLong(symbol, qty, 1); err != nil {
panic(fmt.Errorf("open long failed: %w", err))
}
time.Sleep(2 * time.Second)
mid, _ := fetchState(ex.HyperliquidWalletAddr)
pos := positionSize(mid, "xyz:HOOD")
fmt.Printf("AFTER_OPEN HOOD_szi=%.6f\n", pos)
closeQty := qty
if pos > 0 && pos < closeQty {
closeQty = pos
}
if closeQty > 0 {
fmt.Printf("CLOSE_LONG symbol=%s qty=%.6f builderRequired=true\n", symbol, closeQty)
if _, err := tr.CloseLong(symbol, closeQty); err != nil {
panic(fmt.Errorf("close long failed; manual intervention may be needed for %s size %.6f: %w", symbol, closeQty, err))
}
}
time.Sleep(2 * time.Second)
after, err := fetchState(ex.HyperliquidWalletAddr)
if err != nil {
panic(err)
}
fmt.Printf("AFTER_CLOSE accountValue=%s withdrawable=%s HOOD_szi=%.6f\n", after.CrossMarginSummary.AccountValue, after.Withdrawable, positionSize(after, "xyz:HOOD"))
fmt.Fprintln(os.Stdout, "E2E_BUILDER_FEE_REAL_XYZ_STOCK_TRADE_DONE")
}

View File

@@ -1,13 +1,23 @@
package config
import (
"nofx/telemetry"
"fmt"
"nofx/mcp"
"nofx/telemetry"
"os"
"strconv"
"strings"
)
// insecureDefaultJWTSecret is the historical fallback value. Refusing to boot when
// JWT_SECRET matches it (or is missing) prevents the server from silently signing
// tokens with a well-known secret.
const insecureDefaultJWTSecret = "default-jwt-secret-change-in-production"
// minJWTSecretLength is the minimum byte length we accept for HS256 signing keys.
// HS256 keys shorter than 32 bytes are brute-forceable.
const minJWTSecretLength = 32
// Global configuration instance
var global *Config
@@ -45,8 +55,24 @@ type Config struct {
}
// Init initializes global configuration (from .env)
// MustInit initializes global configuration or panics. Use from main() so the
// process refuses to start under an insecure config (e.g. default JWT secret).
func MustInit() {
if err := initConfig(); err != nil {
panic(fmt.Sprintf("config: %v", err))
}
}
// Init initializes global configuration (from .env). Prefer MustInit from main.
func Init() {
if err := initConfig(); err != nil {
// Preserve historical fail-soft behavior for non-main callers (tests, tools);
// the process can still observe the error via Get() returning nil.
fmt.Fprintf(os.Stderr, "config init failed: %v\n", err)
}
}
func initConfig() error {
cfg := &Config{
APIServerPort: 8080,
ExperienceImprovement: true, // Default: enabled to help improve the product
@@ -65,7 +91,13 @@ func Init() {
cfg.JWTSecret = strings.TrimSpace(v)
}
if cfg.JWTSecret == "" {
cfg.JWTSecret = "default-jwt-secret-change-in-production"
return fmt.Errorf("JWT_SECRET is required (set a random %d+ byte value in .env)", minJWTSecretLength)
}
if cfg.JWTSecret == insecureDefaultJWTSecret {
return fmt.Errorf("JWT_SECRET matches the insecure default; generate a fresh random value (e.g. `openssl rand -base64 48`)")
}
if len(cfg.JWTSecret) < minJWTSecretLength {
return fmt.Errorf("JWT_SECRET must be at least %d bytes (got %d); generate via `openssl rand -base64 48`", minJWTSecretLength, len(cfg.JWTSecret))
}
if v := os.Getenv("API_SERVER_PORT"); v != "" {
@@ -134,6 +166,7 @@ func Init() {
OutputTokens: usage.CompletionTokens,
})
}
return nil
}
// Get returns the global configuration

View File

@@ -282,13 +282,17 @@ func isEncryptedStorageValue(value string) bool {
}
func (cs *CryptoService) DecryptPayload(payload *EncryptedPayload) ([]byte, error) {
// 1. Validate timestamp (prevent replay attacks)
if payload.TS != 0 {
// 1. Validate timestamp (prevent replay attacks).
// The timestamp is mandatory: a missing/zero ts previously skipped this check
// entirely, which let a captured ciphertext be replayed indefinitely. The
// client (web/src/lib/crypto.ts) always stamps ts, so requiring it is safe.
if payload.TS == 0 {
return nil, errors.New("missing timestamp")
}
elapsed := time.Since(time.Unix(payload.TS, 0))
if elapsed > 5*time.Minute || elapsed < -1*time.Minute {
return nil, errors.New("timestamp invalid or expired")
}
}
// 2. Decode base64url
wrappedKey, err := base64.RawURLEncoding.DecodeString(payload.WrappedKey)
@@ -455,8 +459,11 @@ func (es EncryptedString) Value() (driver.Value, error) {
if globalCryptoService != nil {
encrypted, err := globalCryptoService.EncryptForStorage(string(es))
if err != nil {
// If encryption fails, return the original value
return string(es), nil
// Fail closed: never silently persist a plaintext secret when
// encryption was expected to happen. Returning the error aborts the
// write so a misconfigured/broken crypto service cannot leak
// credentials into the database in cleartext.
return nil, fmt.Errorf("failed to encrypt sensitive field for storage: %w", err)
}
return encrypted, nil
}

View File

@@ -9,8 +9,14 @@ services:
stop_grace_period: 30s # Allow the app 30 seconds for graceful shutdown
ports:
- "${NOFX_BACKEND_PORT:-8080}:8080"
- "6060:6060" # pprof profiling
# pprof profiling is bound to host loopback only; uncomment for local debug.
# - "127.0.0.1:6060:6060"
volumes:
# NOTE: .env is bind-mounted so the beginner-onboarding flow
# (persistBeginnerWalletEnv) can write CLAW402_WALLET_* back to the host
# file. Without this mount the wallet is regenerated on every container
# restart. For threat models where the .env file should not be reachable
# via container RCE, deploy via env vars only and remove this mount.
- ./.env:/app/.env
- ./data:/app/data
- /etc/localtime:/etc/localtime:ro

View File

@@ -0,0 +1,44 @@
<svg width="1200" height="380" viewBox="0 0 1200 380" xmlns="http://www.w3.org/2000/svg">
<defs>
<clipPath id="frame"><rect width="1200" height="380" rx="14"/></clipPath>
<pattern id="dots" width="28" height="28" patternUnits="userSpaceOnUse">
<circle cx="1.5" cy="1.5" r="1.2" fill="#1A1813" opacity="0.07"/>
</pattern>
</defs>
<g clip-path="url(#frame)">
<rect width="1200" height="380" fill="#F1ECE2"/>
<rect width="1200" height="380" fill="url(#dots)"/>
<!-- title bar -->
<rect width="1200" height="52" fill="#F7F4EC"/>
<line x1="0" y1="52.5" x2="1200" y2="52.5" stroke="#1A1813" stroke-opacity="0.14"/>
<circle cx="30" cy="26" r="6" fill="#D6433A" opacity="0.9"/>
<circle cx="54" cy="26" r="6" fill="#B4B2A9"/>
<circle cx="78" cy="26" r="6" fill="#2E8B57" opacity="0.9"/>
<text x="600" y="31" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace" font-size="13" letter-spacing="2" fill="#8A8478">nofx — ai trading terminal</text>
<!-- wordmark -->
<text x="64" y="182" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace" font-size="92" font-weight="700" letter-spacing="4" fill="#1A1813">NOFX<tspan fill="#E0483B">_</tspan></text>
<text x="64" y="228" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace" font-size="17" letter-spacing="9" fill="#6B6557">AI TRADING TERMINAL</text>
<text x="64" y="286" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace" font-size="15" fill="#6B6557">Language models make the decisions.</text>
<text x="64" y="312" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace" font-size="15" fill="#6B6557">A Go runtime enforces the limits.</text>
<!-- divider -->
<line x1="672" y1="84" x2="672" y2="344" stroke="#1A1813" stroke-opacity="0.12"/>
<!-- session log -->
<g font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace" font-size="14.5">
<text x="712" y="112" fill="#8A8478">$ nofx start</text>
<text x="712" y="146"><tspan fill="#2E8B57"></tspan><tspan fill="#1A1813" dx="10">preflight passed</tspan><tspan fill="#8A8478" dx="10">7/7 checks</tspan></text>
<text x="712" y="180"><tspan fill="#2E8B57"></tspan><tspan fill="#1A1813" dx="10">trader online</tspan><tspan fill="#8A8478" dx="10">deepseek · hyperliquid</tspan></text>
<text x="712" y="222"><tspan fill="#8A8478">decision</tspan><tspan fill="#E0483B" dx="12" font-weight="700">OPEN_LONG</tspan><tspan fill="#1A1813" dx="12">BTC ×3</tspan></text>
<text x="712" y="252"><tspan fill="#8A8478">reason</tspan><tspan fill="#6B6557" dx="12">oi rising · funding neutral · 4h breakout</tspan></text>
<text x="712" y="282"><tspan fill="#8A8478">risk</tspan><tspan fill="#6B6557" dx="12">sl 2.5% · tp +5.0% · placed on exchange</tspan></text>
<text x="712" y="324"><tspan fill="#8A8478">equity</tspan><tspan fill="#1A1813" dx="12">$12,847</tspan><tspan fill="#8A8478" dx="18">pnl</tspan><tspan fill="#2E8B57" dx="12">+4.2%</tspan></text>
</g>
</g>
<rect x="0.5" y="0.5" width="1199" height="379" rx="14" fill="none" stroke="#1A1813" stroke-opacity="0.22"/>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -12,8 +12,11 @@ NOFX 文档提供多种语言版本。
|----------|-------------|--------|-------------|
| 🇬🇧 **English** | [README.md](../../README.md) | ✅ Complete | Core Team |
| 🇨🇳 **Chinese (中文)** | [README.md](zh-CN/README.md) | ✅ Complete | Community |
| 🇯🇵 **Japanese (日本語)** | [README.md](ja/README.md) | ✅ Complete | Community |
| 🇰🇷 **Korean (한국어)** | [README.md](ko/README.md) | ✅ Complete | Community |
| 🇷🇺 **Russian (Русский)** | [README.md](ru/README.md) | ✅ Complete | Community |
| 🇺🇦 **Ukrainian (Українська)** | [README.md](uk/README.md) | ✅ Complete | Community |
| 🇻🇳 **Vietnamese (Tiếng Việt)** | [README.md](vi/README.md) | ✅ Complete | Community |
---
@@ -30,6 +33,16 @@ NOFX 文档提供多种语言版本。
- **安全政策:** [../../SECURITY.md](../../SECURITY.md#中文)
- **常见问题:** [../guides/faq.zh-CN.md](../guides/faq.zh-CN.md)
### 日本語 🇯🇵
- **メイン README:** [ja/README.md](ja/README.md)
- **Contributing:** [../../CONTRIBUTING.md](../../CONTRIBUTING.md)
- **Security:** [../../SECURITY.md](../../SECURITY.md)
### 한국어 🇰🇷
- **메인 README:** [ko/README.md](ko/README.md)
- **Contributing:** [../../CONTRIBUTING.md](../../CONTRIBUTING.md)
- **Security:** [../../SECURITY.md](../../SECURITY.md)
### Русский 🇷🇺
- **Основной README:** [ru/README.md](ru/README.md)
- **Руководство по участию:** [../../CONTRIBUTING.md](../../CONTRIBUTING.md)
@@ -40,6 +53,11 @@ NOFX 文档提供多种语言版本。
- **Посібник із внесків:** [../../CONTRIBUTING.md](../../CONTRIBUTING.md)
- **Політика безпеки:** [../../SECURITY.md](../../SECURITY.md)
### Tiếng Việt 🇻🇳
- **README chính:** [vi/README.md](vi/README.md)
- **Contributing:** [../../CONTRIBUTING.md](../../CONTRIBUTING.md)
- **Security:** [../../SECURITY.md](../../SECURITY.md)
---
## 🤝 Help with Translations / 帮助翻译
@@ -49,7 +67,7 @@ NOFX 文档提供多种语言版本。
We welcome translation contributions! / 我们欢迎翻译贡献!
**What needs translation? / 需要翻译什么?**
- ✅ Main README (complete for 4 languages)
- ✅ Main README (complete for 7 languages)
- 🚧 Deployment guides (partial)
- 📋 User guides (needed)
- 📋 Contributing guide (needed for RU/UK)
@@ -94,10 +112,11 @@ faq.zh-CN.md → Chinese FAQ
**Language Codes:**
- `en` - English (default, no suffix needed)
- `zh-CN` - Simplified Chinese
- `ja` - Japanese
- `ko` - Korean
- `ru` - Russian
- `uk` - Ukrainian
- `ja` - Japanese *(future)*
- `ko` - Korean *(future)*
- `vi` - Vietnamese
### Quality Standards / 质量标准

View File

@@ -1,22 +1,14 @@
<h1 align="center">NOFX</h1>
<p align="center"><strong><a href="https://vergex.trade">vergex.trade</a> の支援を受けています</strong></p>
<p align="center">
<strong>あなた専属の AI トレーディングアシスタント。</strong><br/>
<strong>あらゆる市場。あらゆるモデル。API キー不要、USDC で支払い。</strong>
<img src="../../assets/nofx-banner.svg" alt="NOFX — AI トレーディングターミナル" width="100%"/>
</p>
<p align="center">
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
</p>
<p align="center">
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
<a href="https://x402.org"><img src="https://img.shields.io/badge/x402-USDC%20Payments-2775CA?style=flat" alt="x402"></a>
<a href="https://claw402.ai"><img src="https://img.shields.io/badge/Claw402-AI%20Gateway-FF6B35?style=flat" alt="Claw402"></a>
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Stars"></a>
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Release"></a>
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-E0483B?style=flat-square&labelColor=1A1813" alt="License"></a>
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/telegram-community-E0483B?style=flat-square&labelColor=1A1813&logo=telegram&logoColor=white" alt="Telegram"></a>
</p>
<p align="center">
@@ -29,159 +21,262 @@
<a href="../vi/README.md">Tiếng Việt</a>
</p>
---
<br/>
NOFX はオープンソースの**自律型** AI トレーディングアシスタントです。従来の AI ツールのように手動でモデルを設定し、API キーを管理し、データソースを接続する必要はありません — NOFX の AI は**市場を自ら認識し、モデルを自ら選択し、データを自ら取得します**。人間の介入はゼロ。あなたは戦略を設定するだけ、残りは AI が処理します。
NOFX は、戦略そのものが言語モデルであるオープンソースのトレーディングターミナルです。各トレーダーは、市場構造を読み取り、判断し、執行し、その根拠を記録するという連続的なループを回し続けます。その間、Go ランタイムがすべての注文を、モデルには上書きできないハードなリスク制限内に抑え込みます。
**完全自律**: AI がどのモデルを使うか、どの市場データを取得するか、いつ取引するかを自ら判断します。手動のモデル設定不要。複数サービスの API キー管理不要。USDC ウォレットに入金して実行するだけ
他との違い:**[x402](https://x402.org) マイクロペイメント内蔵**。API キー不要。USDC ウォレットに入金してリクエストごとに支払い。ウォレットがあなたの身分証明。
トレーダーの構成は自由です。任意のモデル、9 つの取引所のいずれか、任意の戦略を組み合わせられます。複数のトレーダーを並行して走らせ、実現リターンに基づく公開リーダーボードで比較できます。すべては自分のマシン上で動作し、取引所の認証情報は保存時に暗号化され、外部に送信されることはありません
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
**http://127.0.0.1:3000** を開く。完了
ターミナルは `http://127.0.0.1:3000` で開きます
---
**初回起動**
## クイックデモ
1. 登録します — 最初に作成したアカウントがこのインスタンスのオーナーになります。
2. ガイド付きの起動手順に従います。自動作成される AI 手数料ウォレットに **$1+ USDC**Base ネットワーク)を入金し、続いて Hyperliquid を接続して取引資金として **$12+ USDC** を入金します。
3. **Autopilot** を開始します。AI は数分ごとに市場をスキャンして自律的に取引し、すべての判断はその場でダッシュボードに表示されます。ワンクリックでいつでも停止できます。
<p align="center">
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
<img src="../../../screenshots/demo-cover.png" alt="NOFX クイックデモ動画" width="900"/>
</a>
</p>
<br/>
<p align="center">
カバー画像をクリックするとデモ動画を視聴できます。
</p>
## 取引所の登録
---
NOFX は無料のオープンソースソフトウェアです。以下のパートナーリンク経由で口座を開設すると、取引手数料の割引が受けられるうえ、継続的な開発の資金にもなります。
## x402 の仕組み
従来のフロー:アカウント登録 → クレジット購入 → API キー取得 → クォータ管理 → キーのローテーション。
x402 フロー:
```
リクエスト → 402価格提示→ ウォレットが USDC を署名 → リトライ → 完了
```
アカウント不要。API キー不要。前払いクレジット不要。ウォレット1つで全モデル。
### 内蔵 x402 プロバイダー
| プロバイダー | チェーン | モデル |
|:---------|:------|:-------|
| <img src="../../../web/public/icons/claw402.png" width="20" height="20" style="vertical-align: middle;"/> **[Claw402](https://claw402.ai)** | Base | GPT-5.4, Claude Opus, DeepSeek, Qwen, Grok, Gemini, Kimi — 15+ モデル |
---
## 機能
| 機能 | 説明 |
|:--------|:------------|
| **マルチ AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — いつでも切替 |
| **マルチ取引所** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
| **ストラテジースタジオ** | ビジュアルビルダー — コインソース、インジケーター、リスク管理 |
| **AI ディベートアリーナ** | 複数 AI が取引を議論(ブル vs ベア vs アナリスト)、投票、実行 |
| **AI 競争** | AI がリアルタイムで競争、リーダーボードで成績ランキング |
| **Telegram エージェント** | トレーディングアシスタントとチャット — ストリーミング、ツール呼び出し、メモリ |
| **バックテストラボ** | 過去データシミュレーション、エクイティカーブと成績指標 |
| **ダッシュボード** | ライブポジション、損益、Chain of Thought 付き AI 判断ログ |
### 市場
暗号通貨 · 米国株 · FX · 貴金属
### 取引所 (CEX)
| 取引所 | ステータス | 登録 (手数料割引) |
|:---------|:------:|:------------------------|
| 取引所 | 状態 | 手数料割引付きで登録 |
| :---------------------------------------------------------------------------------------------------------------------------- | :----: | :---------------------------------------------------------------------------------- |
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [登録](https://www.binance.com/join?ref=NOFXENG) |
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [登録](https://partner.bybit.com/b/83856) |
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [登録](https://www.okx.com/join/1865360) |
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [登録](https://app.hyperliquid.xyz/join/AITRADING) |
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [登録](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [登録](https://www.kucoin.com/r/broker/CXEV7XKK) |
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [登録](https://www.gatenode.xyz/share/VQBGUAxY) |
### 取引所 (Perp-DEX)
| 取引所 | ステータス | 登録 (手数料割引) |
|:---------|:------:|:------------------------|
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [登録](https://app.hyperliquid.xyz/join/AITRADING) |
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [登録](https://www.asterdex.com/en/referral/fdfc0e) |
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [登録](https://www.asterdex.com/en/referral/fdfc0e) |
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [登録](https://app.lighter.xyz/?referral=68151432) |
### AI モデル (API キーモード)
<br/>
| AI モデル | ステータス | API キー取得 |
|:---------|:------:|:------------|
| <img src="../../../web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [API キー取得](https://platform.deepseek.com) |
| <img src="../../../web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [API キー取得](https://dashscope.console.aliyun.com) |
| <img src="../../../web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [API キー取得](https://platform.openai.com) |
| <img src="../../../web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [API キー取得](https://console.anthropic.com) |
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [API キー取得](https://aistudio.google.com) |
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [API キー取得](https://console.x.ai) |
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [API キー取得](https://platform.moonshot.cn) |
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [API キー取得](https://platform.minimaxi.com) |
## デモ
### AI モデル (x402 モード — API キー不要)
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505
15+ モデルを [Claw402](https://claw402.ai) 経由で利用 — USDC ウォレットのみ
<br/>
---
## モデルは提案する。ランタイムが決める。
判断は、[Claw402.ai](https://claw402.ai)・Vergex のデータスタックを読み取る言語モデルから生まれます。全市場を方向バイアスとシグナル強度でランク付けするライブシグナルボード、銘柄ごとの Signal Lab ディープシグナル、群衆の「燃料」と「壁」の位置を映すコストベース・清算ヒートマップ、リアルタイムのネットフロー — これらを生のローソク足とトレーダー自身のライブ実績と突き合わせます。ただし、執行はそうではありません。
すべての注文は、モデルの手が届かない、コードで強制されるリスク制限を通過します。
| | |
| :----------------------- | :--------------------------------------------------------------------------------- |
| ポジション制限 | 同時ポジション数の上限、想定元本は口座資産に対する比率で制限、1 シンボルにつき 1 ポジション |
| レバレッジ制限 | モデルの要求とは無関係に、注文サイズの算出時にハードキャップを適用 |
| 取引所側の保護 | すべてのエントリー直後に、ストップロスとテイクプロフィットを取引所側に設置 |
| ドローダウン自動クローズ | ピークから利益を大きく吐き出したポジションは自動的にクローズ |
| 取引スロットリング | 最低保有時間、シンボルごとの再エントリークールダウン、サイクルごと・1 時間ごとのエントリー回数制限 |
| セーフモード | モデルの失敗が繰り返された場合、モデルが回復するまで新規エントリーをブロック |
| 起動前チェック | トレーダーの開始前に、モデルアクセス、ウォレット資金、戦略、取引所残高を検証 |
各判断は、モデルの推論全文とともに保存されます。記録の残らないポジションは存在しません。
<br/>
## ターミナル
| | |
| :--- | :--- |
| **Autopilot** | ガイド付きの起動フロー:資金投入、接続、入金、開始 — 全工程をサーバー側の事前チェックが支えます |
| **Strategy Studio** | スタイルプリセット、コインユニバース、インジケーター、レバレッジ、エントリー確信度、カスタムプロンプト |
| **Competition** | 実現リターンでランク付けされる公開リーダーボード。各エントリーには使用モデルが明記されます |
| **Dashboard** | ライブのポジション、注文、統計、そしてすべての判断の背後にある推論 |
<details>
<summary>スクリーンショット</summary>
<br/>
| 概要 | マーケットチャート |
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
| <img src="../../../screenshots/dashboard-page.png" width="400"/> | <img src="../../../screenshots/dashboard-market-chart.png" width="400"/> |
| 取引統計 | ポジション履歴 |
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
| <img src="../../../screenshots/dashboard-trading-stats.png" width="400"/> | <img src="../../../screenshots/dashboard-position-history.png" width="400"/> |
| 戦略エディタ | インジケーター設定 |
| :------------------------------------------------------: | :----------------------------------------------------------: |
| <img src="../../../screenshots/strategy-studio.png" width="400"/> | <img src="../../../screenshots/strategy-indicators.png" width="400"/> |
| コンペティション | 設定 |
| :-------------------------------------------------------: | :-----------------------------------------------------------: |
| <img src="../../../screenshots/competition-page.png" width="400"/> | <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> |
</details>
<br/>
## モデル
自分の API キーで 8 つのプロバイダーを利用できます — DeepSeek、OpenAI、Claude、Qwen、Gemini、Grok、Kimi、MiniMax。カスタムエンドポイントとカスタムモデル名にも対応しています。
あるいは、キーを一切使わない方法もあります。[Claw402](https://claw402.ai) は x402 プロトコル上で、モデルの利用量を呼び出しごとに USDC で課金します。Base 上のウォレットひとつが、すべての API キーの代わりになります。
| プロバイダー | アクセス |
| :------- | :----- |
| **Claw402** | [公式割引付きの従量課金 AI モデル](https://claw402.ai) |
## マーケット
9 つの取引所すべてで暗号資産の無期限先物を取引できます。Hyperliquid では、同じランタイムが暗号資産に加えて、トークン化された米国株、コモディティ、株価指数、FX、プレ IPO 無期限先物 — TSLA、NVDA、GOLD、SPX、EUR、OPENAI — も取引します。
<br/>
## アーキテクチャ
```
┌─────────────────────────────────────────────────┐
│ Trading Terminal │
│ React · TypeScript · TradingView │
│ Dashboard · Strategy Studio · Competition │
├─────────────────────────────────────────────────┤
│ API Server (Go) │
│ JWT auth · encrypted credential store │
├──────────────┬──────────────┬───────────────────┤
│ Strategy │ Autopilot │ Trader Runtime │
│ Engine │ Preflight │ Risk Engine │
├──────────────┴──────────────┴───────────────────┤
│ AI Model Layer │
│ DeepSeek · OpenAI · Claude · Qwen · Gemini │
│ Grok · Kimi · MiniMax · Claw402 (x402 USDC) │
├─────────────────────────────────────────────────┤
│ Exchange Connectivity │
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
│ KuCoin · Gate · Aster · Lighter │
└─────────────────────────────────────────────────┘
```
<br/>
## インストール
### Linux / macOS
**Linux / macOS**
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
### Railway (クラウド)
**Railway**
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/nofx?referralCode=nofx)
### Docker
**Docker**
```bash
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
docker compose -f docker-compose.prod.yml up -d
```
### ソースから
**Windows** — [Docker Desktop](https://www.docker.com/products/docker-desktop/) をインストールしてから、次を実行します。
```bash
# 前提条件: Go 1.21+, Node.js 18+, TA-Lib
# macOS: brew install ta-lib
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
go build -o nofx && ./nofx # バックエンド
cd web && npm install && npm run dev # フロントエンド(新しいターミナル)
```powershell
curl -o docker-compose.prod.yml https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
docker compose -f docker-compose.prod.yml up -d
```
---
**ソースからビルド** — Go 1.21+、Node.js 18+ が必要です。
## リンク
```bash
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
go build -o nofx && ./nofx # backend
cd web && npm install && npm run dev # frontend, in a second terminal
```
**アップデート** — インストールスクリプトを再実行すると、その場でアップグレードされます。
<details>
<summary>サーバーへのデプロイ</summary>
<br/>
**HTTP**
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
# http://YOUR_IP:3000
```
**Cloudflare 経由の HTTPS**
1. [Cloudflare](https://dash.cloudflare.com)(無料プラン)にドメインを追加
2. A レコード → サーバー IPProxied を有効化)
3. SSL/TLS → Flexible
4. `.env``TRANSPORT_ENCRYPTION=true` を設定
</details>
<br/>
## ドキュメント
| | |
|:--|:--|
| ウェブサイト | [nofxai.com](https://nofxai.com) |
| ダッシュボード | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
| API ドキュメント | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
| Twitter | [@nofx_official](https://x.com/nofx_official) |
| :------------------------------------------------------ | :------------------------------------ |
| [はじめに](../../getting-started/README.md) | デプロイと取引所 API のガイド |
| [アーキテクチャ](../../architecture/README.md) | システム設計とモジュール索引 |
| [戦略モジュール](../../architecture/STRATEGY_MODULE.md) | 銘柄選択、AI プロンプト、執行 |
| [FAQ](../../guides/faq.en.md) | よくある質問 |
| [トラブルシューティング](../../guides/TROUBLESHOOTING.md) | よくある問題の診断 |
> **リスク警告**: AI 自動取引には重大なリスクがあります。学習/研究目的または少額でのテストのみを推奨します。
## コミュニティ
---
[Telegram](https://t.me/nofx_dev_community) · [Twitter/X](https://x.com/vergex_ai) · [Issues](https://github.com/NoFxAiOS/nofx/issues) · [vergex.trade](https://vergex.trade) · [ライブダッシュボード](https://vergex.trade/explore)
## License
## コントリビューション
コード、ドキュメント、翻訳、バグ報告のいずれも歓迎します。詳細は[貢献ガイド](../../../CONTRIBUTING.md)、[行動規範](../../../CODE_OF_CONDUCT.md)、[セキュリティポリシー](../../../SECURITY.md)をご覧ください。
NOFX は有意義な貢献を記録しており、エコシステムの成長に応じて貢献者に還元していく予定です。優先度の高い Issue にはより高いウェイトが付きます。
| 貢献の種類 | ウェイト |
| :---------------- | :----: |
| ピン留め Issue の PR | ★★★★★★ |
| コード(マージ済み PR | ★★★★★ |
| バグ修正 | ★★★★ |
| 機能アイデア | ★★★ |
| バグ報告 | ★★ |
| ドキュメント | ★★ |
<a href="https://github.com/NoFxAiOS/nofx/graphs/contributors">
<img src="https://contrib.rocks/image?repo=NoFxAiOS/nofx" alt="Contributors"/>
</a>
## スポンサー
<a href="https://github.com/pjl914335852-ux"><img src="https://github.com/pjl914335852-ux.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/cat9999aaa"><img src="https://github.com/cat9999aaa.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/1733055465"><img src="https://github.com/1733055465.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/kolal2020"><img src="https://github.com/kolal2020.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/CyberFFarm"><img src="https://github.com/CyberFFarm.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/vip3001003"><img src="https://github.com/vip3001003.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/mrtluh"><img src="https://github.com/mrtluh.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/cpcp1117-source"><img src="https://github.com/cpcp1117-source.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/match-007"><img src="https://github.com/match-007.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/leiwuhen1715"><img src="https://github.com/leiwuhen1715.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/SHAOXIA1991"><img src="https://github.com/SHAOXIA1991.png" width="50" height="50" style="border-radius:50%"/></a>
[スポンサーになる](https://github.com/sponsors/NoFxAiOS)
<br/>
NOFX が役に立ったら、スターを付けていただけると、他のトレーダーがこのプロジェクトを見つけやすくなります。
[![Star History Chart](https://api.star-history.com/svg?repos=NoFxAiOS/nofx&type=Date)](https://star-history.com/#NoFxAiOS/nofx&Date)
## ライセンス
[AGPL-3.0](../../../LICENSE)
[![Star History Chart](https://api.star-history.com/svg?repos=NoFxAiOS/nofx&type=Date)](https://star-history.com/#NoFxAiOS/nofx&Date)
<sub>自動売買には大きなリスクが伴います。AI 駆動の戦略は実験的なものであり、損失を出す可能性があります。適切なポジションサイズを守り、各取引所の仕組みを理解し、失っても差し支えのない資金以外では決して取引しないでください。詳細は[免責事項](../../../DISCLAIMER.md)をご覧ください。</sub>

View File

@@ -1,22 +1,14 @@
<h1 align="center">NOFX</h1>
<p align="center"><strong><a href="https://vergex.trade">vergex.trade</a>가 지원합니다</strong></p>
<p align="center">
<strong>당신만의 AI 트레이딩 어시스턴트.</strong><br/>
<strong>모든 시장. 모든 모델. API 키 없이 USDC로 결제.</strong>
<img src="../../assets/nofx-banner.svg" alt="NOFX — AI trading terminal" width="100%"/>
</p>
<p align="center">
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
</p>
<p align="center">
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
<a href="https://x402.org"><img src="https://img.shields.io/badge/x402-USDC%20Payments-2775CA?style=flat" alt="x402"></a>
<a href="https://claw402.ai"><img src="https://img.shields.io/badge/Claw402-AI%20Gateway-FF6B35?style=flat" alt="Claw402"></a>
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Stars"></a>
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Release"></a>
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-E0483B?style=flat-square&labelColor=1A1813" alt="License"></a>
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/telegram-community-E0483B?style=flat-square&labelColor=1A1813&logo=telegram&logoColor=white" alt="Telegram"></a>
</p>
<p align="center">
@@ -29,159 +21,262 @@
<a href="../vi/README.md">Tiếng Việt</a>
</p>
---
<br/>
NOFX는 오픈소스 **자율형** AI 트레이딩 어시스턴트입니다. 수동으로 모델을 설정하고, API 키를 관리하고, 데이터 소스를 연결해야 하는 기존 AI 도구와 달리 — NOFX의 AI는 **시장을 스스로 인식하고, 모델을 스스로 선택하고, 데이터를 스스로 가져옵니다**. 인간 개입 제로. 전략만 설정하면 나머지는 AI가 처리합니다.
NOFX는 언어 모델이 곧 전략이 되는 오픈소스 트레이딩 터미널입니다. 각 트레이더는 시장 구조 읽기, 판단, 실행, 근거 기록으로 이어지는 루프를 끊임없이 반복하고, Go 런타임은 모든 주문을 모델이 절대 우회할 수 없는 하드 리스크 한도 안에 묶어 둡니다.
**완전 자율**: AI가 어떤 모델을 사용할지, 어떤 시장 데이터를 가져올지, 언제 거래할지를 스스로 결정합니다. 수동 모델 설정 불필요. 여러 서비스의 API 키 관리 불필요. USDC 지갑에 충전하고 실행하기만 하면 됩니다.
차별점: **[x402](https://x402.org) 마이크로 결제 내장**. API 키 불필요. USDC 지갑에 충전하고 요청마다 결제. 지갑이 곧 신원.
트레이더는 자유롭게 조합할 수 있습니다. 어떤 모델이든, 아홉 개 거래소 중 어디든, 어떤 전략이든 가능합니다. 여러 트레이더를 나란히 돌리면서 실현 수익률 기준의 공개 리더보드에서 비교해 보세요. 모든 것은 사용자의 머신에서 실행되며, 거래소 자격 증명은 저장 시 암호화되고 절대 외부로 나가지 않습니다.
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
**http://127.0.0.1:3000** 을 열면 완료.
터미널은 `http://127.0.0.1:3000`에서 열립니다.
---
**첫 실행**
## 빠른 데모
1. 계정을 등록합니다 — 첫 번째 계정이 해당 인스턴스의 소유자가 됩니다.
2. 가이드 런치를 따라갑니다. 자동으로 생성된 AI 수수료 지갑에 **$1+ USDC**(Base 네트워크)를 넣은 뒤, Hyperliquid를 연결하고 거래 자금으로 **$12+ USDC**를 입금합니다.
3. **Autopilot**을 시작합니다. AI가 몇 분마다 시장을 스캔하며 스스로 거래하고, 모든 결정은 발생하는 즉시 대시보드에 표시됩니다. 언제든 클릭 한 번으로 중지할 수 있습니다.
<p align="center">
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
<img src="../../../screenshots/demo-cover.png" alt="NOFX 빠른 데모 영상" width="900"/>
</a>
</p>
<br/>
<p align="center">
커버 이미지를 클릭하면 데모 영상을 볼 수 있습니다.
</p>
## 거래소 등록
---
NOFX는 무료 오픈소스입니다. 아래 파트너 링크로 계정을 개설하면 거래 수수료가 할인되며, 프로젝트의 지속적인 개발에도 보탬이 됩니다.
## x402 작동 방식
기존 플로우: 계정 등록 → 크레딧 구매 → API 키 받기 → 쿼터 관리 → 키 교체.
x402 플로우:
```
요청 → 402 (가격 제시) → 지갑이 USDC 서명 → 재시도 → 완료
```
계정 불필요. API 키 불필요. 선불 크레딧 불필요. 지갑 하나로 모든 모델.
### 내장 x402 프로바이더
| 프로바이더 | 체인 | 모델 |
|:---------|:------|:-------|
| <img src="../../../web/public/icons/claw402.png" width="20" height="20" style="vertical-align: middle;"/> **[Claw402](https://claw402.ai)** | Base | GPT-5.4, Claude Opus, DeepSeek, Qwen, Grok, Gemini, Kimi — 15+ 모델 |
---
## 기능
| 기능 | 설명 |
|:--------|:------------|
| **멀티 AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — 언제든 전환 |
| **멀티 거래소** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
| **전략 스튜디오** | 비주얼 빌더 — 코인 소스, 지표, 리스크 관리 |
| **AI 토론 아레나** | 여러 AI가 거래 토론 (강세 vs 약세 vs 분석가), 투표, 실행 |
| **AI 경쟁** | AI가 실시간 경쟁, 리더보드 순위 |
| **Telegram 에이전트** | 트레이딩 어시스턴트와 채팅 — 스트리밍, 도구 호출, 메모리 |
| **백테스트 랩** | 과거 시뮬레이션, 자산 곡선 및 성과 지표 |
| **대시보드** | 실시간 포지션, 손익, Chain of Thought AI 결정 로그 |
### 시장
암호화폐 · 미국 주식 · 외환 · 귀금속
### 거래소 (CEX)
| 거래소 | 상태 | 등록 (수수료 할인) |
|:---------|:------:|:------------------------|
| 거래소 | 상태 | 수수료 할인 등록 |
| :---------------------------------------------------------------------------------------------------------------------------- | :----: | :---------------------------------------------------------------------------------- |
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [등록](https://www.binance.com/join?ref=NOFXENG) |
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [등록](https://partner.bybit.com/b/83856) |
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [등록](https://www.okx.com/join/1865360) |
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [등록](https://app.hyperliquid.xyz/join/AITRADING) |
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [등록](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [등록](https://www.kucoin.com/r/broker/CXEV7XKK) |
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [등록](https://www.gatenode.xyz/share/VQBGUAxY) |
### 거래소 (Perp-DEX)
| 거래소 | 상태 | 등록 (수수료 할인) |
|:---------|:------:|:------------------------|
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [등록](https://app.hyperliquid.xyz/join/AITRADING) |
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [등록](https://www.asterdex.com/en/referral/fdfc0e) |
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [등록](https://www.asterdex.com/en/referral/fdfc0e) |
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [등록](https://app.lighter.xyz/?referral=68151432) |
### AI 모델 (API 키 모드)
<br/>
| AI 모델 | 상태 | API 키 받기 |
|:---------|:------:|:------------|
| <img src="../../../web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [API 키 받기](https://platform.deepseek.com) |
| <img src="../../../web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [API 키 받기](https://dashscope.console.aliyun.com) |
| <img src="../../../web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [API 키 받기](https://platform.openai.com) |
| <img src="../../../web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [API 키 받기](https://console.anthropic.com) |
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [API 키 받기](https://aistudio.google.com) |
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [API 키 받기](https://console.x.ai) |
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [API 키 받기](https://platform.moonshot.cn) |
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [API 키 받기](https://platform.minimaxi.com) |
## 데모
### AI 모델 (x402 모드 — API 키 불필요)
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505
15+ 모델을 [Claw402](https://claw402.ai)로 이용 — USDC 지갑만 있으면 됩니다
<br/>
---
## 모델은 제안하고, 런타임이 결정합니다
의사결정은 [Claw402.ai](https://claw402.ai) · Vergex 데이터 스택을 읽는 언어 모델에서 나옵니다. 모든 시장을 방향 편향과 시그널 강도로 순위 매기는 실시간 시그널 보드, 종목별 Signal Lab 딥 시그널, 시장의 '연료'와 '벽'이 어디에 있는지 보여주는 코스트·청산 히트맵, 실시간 순유입 — 이를 원시 캔들과 트레이더 자신의 실시간 트랙 레코드로 교차 검증합니다. 그러나 실행은 그렇지 않습니다.
모든 주문은 모델의 손이 닿지 않는 곳에서 코드로 강제되는 한도를 통과해야 합니다.
| | |
| :----------------------- | :--------------------------------------------------------------------------------- |
| 포지션 한도 | 최대 동시 포지션 수, 자본 대비 비율로 상한이 걸린 명목 금액, 심볼당 포지션 1개 |
| 레버리지 클램프 | 모델이 무엇을 요청하든 관계없이 주문 크기 산정 시점에 적용되는 하드 캡 |
| 거래소 측 보호 | 모든 진입 직후 거래소에 스탑로스와 테이크프로핏을 즉시 배치 |
| 드로다운 자동 청산 | 고점 대비 이익을 과도하게 반납한 수익 포지션은 자동으로 청산 |
| 거래 스로틀링 | 최소 보유 시간, 심볼별 재진입 쿨다운, 사이클당·시간당 진입 횟수 제한 |
| 세이프 모드 | 모델 오류가 반복되면 모델이 복구될 때까지 신규 진입을 차단 |
| 런치 프리플라이트 | 트레이더 시작 전에 모델 접근성, 지갑 자금, 전략, 거래소 잔고를 검증 |
각 결정은 모델의 전체 추론 과정과 함께 저장됩니다. 기록 없는 포지션은 존재하지 않습니다.
<br/>
## 터미널
| | |
| :--- | :--- |
| **Autopilot** | 가이드 런치: 자금 입금, 연결, 예치, 시작 — 전 과정에 서버 측 프리플라이트 적용 |
| **Strategy Studio** | 스타일 프리셋, 코인 유니버스, 지표, 레버리지, 진입 신뢰도, 커스텀 프롬프트 |
| **경쟁** | 실현 수익률로 순위가 매겨지는 공개 리더보드, 각 항목마다 사용한 모델 표기 |
| **대시보드** | 실시간 포지션, 주문, 통계, 그리고 모든 결정 뒤에 있는 추론 과정 |
<details>
<summary>스크린샷</summary>
<br/>
| 개요 | 마켓 차트 |
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
| <img src="../../../screenshots/dashboard-page.png" width="400"/> | <img src="../../../screenshots/dashboard-market-chart.png" width="400"/> |
| 거래 통계 | 포지션 기록 |
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
| <img src="../../../screenshots/dashboard-trading-stats.png" width="400"/> | <img src="../../../screenshots/dashboard-position-history.png" width="400"/> |
| 전략 에디터 | 지표 설정 |
| :------------------------------------------------------: | :----------------------------------------------------------: |
| <img src="../../../screenshots/strategy-studio.png" width="400"/> | <img src="../../../screenshots/strategy-indicators.png" width="400"/> |
| 경쟁 | 설정 |
| :-------------------------------------------------------: | :-----------------------------------------------------------: |
| <img src="../../../screenshots/competition-page.png" width="400"/> | <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> |
</details>
<br/>
## 모델
자신의 키로 사용할 수 있는 여덟 개 제공업체 — DeepSeek, OpenAI, Claude, Qwen, Gemini, Grok, Kimi, MiniMax — 커스텀 엔드포인트와 모델 이름도 지원합니다.
키가 전혀 없어도 됩니다. [Claw402](https://claw402.ai)는 x402 프로토콜을 통해 모델 사용량을 호출 단위로 USDC로 정산합니다. Base 네트워크의 지갑 하나가 모든 API 키를 대신합니다.
| 제공업체 | 이용 방법 |
| :------- | :----- |
| **Claw402** | [공식 할인이 적용된 종량제 AI 모델](https://claw402.ai) |
## 시장
아홉 개 거래소 전체에서 암호화폐 무기한 선물을 거래합니다. Hyperliquid에서는 같은 런타임이 암호화폐와 함께 토큰화된 미국 주식, 원자재, 지수, 외환, 프리 IPO 무기한 선물 — TSLA, NVDA, GOLD, SPX, EUR, OPENAI — 도 거래합니다.
<br/>
## 아키텍처
```
┌─────────────────────────────────────────────────┐
│ Trading Terminal │
│ React · TypeScript · TradingView │
│ Dashboard · Strategy Studio · Competition │
├─────────────────────────────────────────────────┤
│ API Server (Go) │
│ JWT auth · encrypted credential store │
├──────────────┬──────────────┬───────────────────┤
│ Strategy │ Autopilot │ Trader Runtime │
│ Engine │ Preflight │ Risk Engine │
├──────────────┴──────────────┴───────────────────┤
│ AI Model Layer │
│ DeepSeek · OpenAI · Claude · Qwen · Gemini │
│ Grok · Kimi · MiniMax · Claw402 (x402 USDC) │
├─────────────────────────────────────────────────┤
│ Exchange Connectivity │
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
│ KuCoin · Gate · Aster · Lighter │
└─────────────────────────────────────────────────┘
```
<br/>
## 설치
### Linux / macOS
**Linux / macOS**
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
### Railway (클라우드)
**Railway**
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/nofx?referralCode=nofx)
### Docker
**Docker**
```bash
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
docker compose -f docker-compose.prod.yml up -d
```
### 소스에서
**Windows** — [Docker Desktop](https://www.docker.com/products/docker-desktop/)을 설치한 뒤:
```bash
# 필수 조건: Go 1.21+, Node.js 18+, TA-Lib
# macOS: brew install ta-lib
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
go build -o nofx && ./nofx # 백엔드
cd web && npm install && npm run dev # 프론트엔드 (새 터미널)
```powershell
curl -o docker-compose.prod.yml https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
docker compose -f docker-compose.prod.yml up -d
```
---
**소스에서 빌드** — Go 1.21+, Node.js 18+:
## 링크
```bash
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
go build -o nofx && ./nofx # backend
cd web && npm install && npm run dev # frontend, in a second terminal
```
**업데이트** — 설치 스크립트를 다시 실행하면 제자리에서 업그레이드됩니다.
<details>
<summary>서버 배포</summary>
<br/>
**HTTP**
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
# http://YOUR_IP:3000
```
**Cloudflare를 통한 HTTPS**
1. [Cloudflare](https://dash.cloudflare.com)에 도메인 추가(무료 플랜)
2. A 레코드 → 서버 IP, Proxied 설정
3. SSL/TLS → Flexible
4. `.env``TRANSPORT_ENCRYPTION=true` 설정
</details>
<br/>
## 문서
| | |
|:--|:--|
| 웹사이트 | [nofxai.com](https://nofxai.com) |
| 대시보드 | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
| API 문서 | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
| Twitter | [@nofx_official](https://x.com/nofx_official) |
| :------------------------------------------------------ | :------------------------------------ |
| [시작하기](../../getting-started/README.md) | 배포 및 거래소 API 가이드 |
| [아키텍처](../../architecture/README.md) | 시스템 설계와 모듈 색인 |
| [전략 모듈](../../architecture/STRATEGY_MODULE.md) | 코인 선정, AI 프롬프트, 실행 |
| [FAQ](../../guides/faq.en.md) | 자주 묻는 질문 |
| [문제 해결](../../guides/TROUBLESHOOTING.md) | 흔한 문제 진단하기 |
> **위험 경고**: AI 자동 거래에는 상당한 위험이 있습니다. 학습/연구 또는 소액 테스트만 권장합니다.
## 커뮤니티
---
[Telegram](https://t.me/nofx_dev_community) · [Twitter/X](https://x.com/vergex_ai) · [Issues](https://github.com/NoFxAiOS/nofx/issues) · [vergex.trade](https://vergex.trade) · [라이브 대시보드](https://vergex.trade/explore)
## License
## 기여
코드, 문서, 번역, 버그 리포트 모두 환영합니다 — [기여 가이드](../../../CONTRIBUTING.md), [행동 강령](../../../CODE_OF_CONDUCT.md), [보안 정책](../../../SECURITY.md)을 참고하세요.
NOFX는 의미 있는 기여를 기록하며, 생태계가 성장함에 따라 기여자에게 보상할 계획입니다. 우선순위 이슈에는 더 높은 가중치가 부여됩니다.
| 기여 유형 | 가중치 |
| :---------------- | :----: |
| 고정 이슈 PR | ★★★★★★ |
| 코드(머지된 PR) | ★★★★★ |
| 버그 수정 | ★★★★ |
| 기능 아이디어 | ★★★ |
| 버그 리포트 | ★★ |
| 문서 | ★★ |
<a href="https://github.com/NoFxAiOS/nofx/graphs/contributors">
<img src="https://contrib.rocks/image?repo=NoFxAiOS/nofx" alt="Contributors"/>
</a>
## 스폰서
<a href="https://github.com/pjl914335852-ux"><img src="https://github.com/pjl914335852-ux.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/cat9999aaa"><img src="https://github.com/cat9999aaa.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/1733055465"><img src="https://github.com/1733055465.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/kolal2020"><img src="https://github.com/kolal2020.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/CyberFFarm"><img src="https://github.com/CyberFFarm.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/vip3001003"><img src="https://github.com/vip3001003.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/mrtluh"><img src="https://github.com/mrtluh.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/cpcp1117-source"><img src="https://github.com/cpcp1117-source.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/match-007"><img src="https://github.com/match-007.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/leiwuhen1715"><img src="https://github.com/leiwuhen1715.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/SHAOXIA1991"><img src="https://github.com/SHAOXIA1991.png" width="50" height="50" style="border-radius:50%"/></a>
[스폰서 되기](https://github.com/sponsors/NoFxAiOS)
<br/>
NOFX가 도움이 되었다면, 스타 하나가 다른 트레이더들이 이 프로젝트를 발견하는 데 큰 힘이 됩니다.
[![Star History Chart](https://api.star-history.com/svg?repos=NoFxAiOS/nofx&type=Date)](https://star-history.com/#NoFxAiOS/nofx&Date)
## 라이선스
[AGPL-3.0](../../../LICENSE)
[![Star History Chart](https://api.star-history.com/svg?repos=NoFxAiOS/nofx&type=Date)](https://star-history.com/#NoFxAiOS/nofx&Date)
<sub>자동매매에는 상당한 위험이 따릅니다. AI 기반 전략은 실험적이며 손실이 발생할 수 있습니다. 포지션 규모를 적절히 관리하고, 각 거래소의 구조를 이해하며, 잃어도 감당할 수 있는 자금으로만 거래하세요. 전체 [면책 조항](../../../DISCLAIMER.md)을 확인하세요.</sub>

View File

@@ -1,22 +1,14 @@
<h1 align="center">NOFX</h1>
<p align="center"><strong>При поддержке <a href="https://vergex.trade">vergex.trade</a></strong></p>
<p align="center">
<strong>Ваш персональный AI торговый ассистент.</strong><br/>
<strong>Любой рынок. Любая модель. Оплата USDC, без API ключей.</strong>
<img src="../../assets/nofx-banner.svg" alt="NOFX — AI trading terminal" width="100%"/>
</p>
<p align="center">
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
</p>
<p align="center">
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
<a href="https://x402.org"><img src="https://img.shields.io/badge/x402-USDC%20Payments-2775CA?style=flat" alt="x402"></a>
<a href="https://claw402.ai"><img src="https://img.shields.io/badge/Claw402-AI%20Gateway-FF6B35?style=flat" alt="Claw402"></a>
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Stars"></a>
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Release"></a>
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-E0483B?style=flat-square&labelColor=1A1813" alt="License"></a>
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/telegram-community-E0483B?style=flat-square&labelColor=1A1813&logo=telegram&logoColor=white" alt="Telegram"></a>
</p>
<p align="center">
@@ -29,160 +21,262 @@
<a href="../vi/README.md">Tiếng Việt</a>
</p>
---
<br/>
NOFX — это **автономный** AI торговый ассистент с открытым исходным кодом. В отличие от традиционных AI инструментов, где нужно вручную настраивать модели, управлять API ключами и подключать источники данных — AI в NOFX **сам анализирует рынки, выбирает модели и получает данные**. Нулевое вмешательство человека. Вы задаёте стратегию, AI делает всё остальное.
NOFX — торговый терминал с открытым исходным кодом, где стратегией выступает языковая модель. Каждый трейдер работает в непрерывном цикле — читает структуру рынка, принимает решение, исполняет его и записывает ход рассуждений, — а рантайм на Go прижимает каждый ордер к жёстким риск-лимитам, которые модель не может обойти.
**Полная автономность**: AI сам решает, какую модель использовать, какие рыночные данные получить, когда торговать. Без ручной настройки моделей. Без жонглирования API ключами разных сервисов. Просто пополните USDC кошелёк и запустите.
Ключевое отличие: **встроенные [x402](https://x402.org) микроплатежи**. Без API ключей. Пополните USDC кошелёк и платите за каждый запрос. Кошелёк — это ваша идентификация.
Трейдеры собираются свободно: любая модель, любая из девяти бирж, любая стратегия. Запускайте несколько параллельно и сравнивайте их в публичной таблице лидеров по реализованной доходности. Всё работает на вашей собственной машине; биржевые ключи шифруются при хранении и никогда её не покидают.
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
Откройте **http://127.0.0.1:3000**. Готово.
Терминал откроется по адресу `http://127.0.0.1:3000`.
---
**Первый запуск**
## Быстрое демо
1. Зарегистрируйтесь — первый аккаунт становится владельцем экземпляра.
2. Пройдите пошаговый запуск: положите **$1+ USDC** (сеть Base) в кошелёк для оплаты AI, который он создаст для вас, затем подключите Hyperliquid и внесите **$12+ USDC** для торговли.
3. Запустите **Autopilot**. AI сканирует рынок каждые несколько минут и торгует самостоятельно; каждое решение появляется на дашборде в момент принятия. Остановить можно в любой момент одним кликом.
<p align="center">
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
<img src="../../../screenshots/demo-cover.png" alt="Видео быстрого демо NOFX" width="900"/>
</a>
</p>
<br/>
<p align="center">
Нажмите на изображение обложки, чтобы посмотреть демо-видео.
</p>
## Регистрация на биржах
---
NOFX бесплатен и открыт. Открытие счёта по партнёрским ссылкам ниже даёт сниженные торговые комиссии и поддерживает дальнейшую разработку.
## Как работает x402
| Биржа | Статус | Регистрация со скидкой на комиссии |
| :---------------------------------------------------------------------------------------------------------------------------- | :----: | :---------------------------------------------------------------------------------- |
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [Зарегистрироваться](https://www.binance.com/join?ref=NOFXENG) |
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Зарегистрироваться](https://partner.bybit.com/b/83856) |
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [Зарегистрироваться](https://www.okx.com/join/1865360) |
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Зарегистрироваться](https://app.hyperliquid.xyz/join/AITRADING) |
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [Зарегистрироваться](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [Зарегистрироваться](https://www.kucoin.com/r/broker/CXEV7XKK) |
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [Зарегистрироваться](https://www.gatenode.xyz/share/VQBGUAxY) |
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [Зарегистрироваться](https://www.asterdex.com/en/referral/fdfc0e) |
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [Зарегистрироваться](https://app.lighter.xyz/?referral=68151432) |
Традиционный процесс: регистрация → покупка кредитов → получение API ключа → управление квотой → ротация ключей.
<br/>
x402 процесс:
## Демо
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505
<br/>
## Модель предполагает. Рантайм располагает.
Решения принимает языковая модель, читающая стек данных [Claw402.ai](https://claw402.ai) · Vergex: живой сигнальный борд, ранжирующий каждый рынок по направлению и силе сигнала, глубокие сигналы Signal Lab по каждому инструменту, тепловые карты себестоимости и ликвидаций, показывающие, где у толпы «топливо» и «стены», и чистый поток средств в реальном времени — всё это сверяется с сырыми свечами и собственным живым трек-рекордом трейдера. Исполнение — нет.
Каждый ордер проходит через лимиты, зашитые в код и недоступные модели:
| | |
| :----------------------- | :--------------------------------------------------------------------------------- |
| Лимиты позиций | Максимум одновременных позиций, номинал ограничен долей от капитала, одна позиция на инструмент |
| Ограничение плеча | Жёсткие пределы применяются при расчёте размера ордера, независимо от того, что запросила модель |
| Защита на стороне биржи | Стоп-лосс и тейк-профит выставляются на бирже сразу после каждого входа |
| Автозакрытие по просадке | Прибыльные позиции, отдавшие слишком много от своего пика, закрываются |
| Троттлинг сделок | Минимальное время удержания, кулдауны повторного входа по инструменту, лимиты входов на цикл и на час |
| Безопасный режим | Повторяющиеся сбои модели блокируют новые входы, пока модель не восстановится |
| Предстартовая проверка | Доступ к модели, средства в кошельке, стратегия и балансы бирж проверяются до того, как трейдер сможет стартовать |
Каждое решение сохраняется вместе с полным ходом рассуждений модели. Ни одной позиции без документального следа.
<br/>
## Терминал
| | |
| :--- | :--- |
| **Autopilot** | Пошаговый запуск: пополнить, подключить, внести депозит, стартовать — с серверной предстартовой проверкой на каждом шаге |
| **Strategy Studio** | Пресеты стилей, наборы монет, индикаторы, плечо, порог уверенности для входа, собственные промпты |
| **Competition** | Публичная таблица лидеров по реализованной доходности, каждая запись привязана к своей модели |
| **Dashboard** | Живые позиции, ордера, статистика и обоснование каждого решения |
<details>
<summary>Скриншоты</summary>
<br/>
| Обзор | График рынка |
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
| <img src="../../../screenshots/dashboard-page.png" width="400"/> | <img src="../../../screenshots/dashboard-market-chart.png" width="400"/> |
| Статистика торговли | История позиций |
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
| <img src="../../../screenshots/dashboard-trading-stats.png" width="400"/> | <img src="../../../screenshots/dashboard-position-history.png" width="400"/> |
| Редактор стратегий | Настройка индикаторов |
| :------------------------------------------------------: | :----------------------------------------------------------: |
| <img src="../../../screenshots/strategy-studio.png" width="400"/> | <img src="../../../screenshots/strategy-indicators.png" width="400"/> |
| Соревнование | Конфигурация |
| :-------------------------------------------------------: | :-----------------------------------------------------------: |
| <img src="../../../screenshots/competition-page.png" width="400"/> | <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> |
</details>
<br/>
## Модели
Восемь провайдеров с вашими собственными ключами — DeepSeek, OpenAI, Claude, Qwen, Gemini, Grok, Kimi, MiniMax — включая пользовательские эндпоинты и имена моделей.
Или вовсе без ключей: [Claw402](https://claw402.ai) тарифицирует использование моделей за каждый вызов в USDC по протоколу x402. Кошелёк в сети Base заменяет все API-ключи.
| Провайдер | Доступ |
| :------- | :----- |
| **Claw402** | [AI-модели с оплатой по мере использования и официальной скидкой](https://claw402.ai) |
## Рынки
Криптовалютные бессрочные контракты на всех девяти биржах. На Hyperliquid тот же рантайм торгует также токенизированными акциями США, сырьевыми товарами, индексами, валютными парами и pre-IPO перпетуалами — TSLA, NVDA, GOLD, SPX, EUR, OPENAI — наряду с криптовалютой.
<br/>
## Архитектура
```
Запрос → 402 (вот цена) → кошелёк подписывает USDC → повтор → готово
┌─────────────────────────────────────────────────┐
│ Trading Terminal │
│ React · TypeScript · TradingView │
│ Dashboard · Strategy Studio · Competition │
├─────────────────────────────────────────────────┤
│ API Server (Go) │
│ JWT auth · encrypted credential store │
├──────────────┬──────────────┬───────────────────┤
│ Strategy │ Autopilot │ Trader Runtime │
│ Engine │ Preflight │ Risk Engine │
├──────────────┴──────────────┴───────────────────┤
│ AI Model Layer │
│ DeepSeek · OpenAI · Claude · Qwen · Gemini │
│ Grok · Kimi · MiniMax · Claw402 (x402 USDC) │
├─────────────────────────────────────────────────┤
│ Exchange Connectivity │
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
│ KuCoin · Gate · Aster · Lighter │
└─────────────────────────────────────────────────┘
```
Без аккаунтов. Без API ключей. Без предоплаты. Один кошелёк, все модели.
### Встроенные x402 провайдеры
| Провайдер | Сеть | Модели |
|:---------|:------|:-------|
| <img src="../../../web/public/icons/claw402.png" width="20" height="20" style="vertical-align: middle;"/> **[Claw402](https://claw402.ai)** | Base | GPT-5.4, Claude Opus, DeepSeek, Qwen, Grok, Gemini, Kimi — 15+ моделей |
---
## Возможности
| Функция | Описание |
|:--------|:------------|
| **Мульти-AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — переключение в любой момент |
| **Мульти-биржа** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
| **Студия стратегий** | Визуальный конструктор — источники монет, индикаторы, контроль рисков |
| **AI Арена дебатов** | Несколько AI обсуждают сделки (Бык vs Медведь vs Аналитик), голосуют, исполняют |
| **AI Соревнование** | AI соревнуются в реальном времени, рейтинг в таблице лидеров |
| **Telegram Агент** | Чат с торговым ассистентом — стриминг, вызов инструментов, память |
| **Лаборатория бэктеста** | Историческая симуляция с кривой капитала и метриками |
| **Панель управления** | Позиции в реальном времени, P/L, логи AI решений с Chain of Thought |
### Рынки
Криптовалюта · Акции США · Форекс · Металлы
### Биржи (CEX)
| Биржа | Статус | Регистрация (скидка) |
|:---------|:------:|:------------------------|
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [Регистрация](https://www.binance.com/join?ref=NOFXENG) |
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Регистрация](https://partner.bybit.com/b/83856) |
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [Регистрация](https://www.okx.com/join/1865360) |
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [Регистрация](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [Регистрация](https://www.kucoin.com/r/broker/CXEV7XKK) |
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [Регистрация](https://www.gatenode.xyz/share/VQBGUAxY) |
### Биржи (Perp-DEX)
| Биржа | Статус | Регистрация (скидка) |
|:---------|:------:|:------------------------|
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Регистрация](https://app.hyperliquid.xyz/join/AITRADING) |
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [Регистрация](https://www.asterdex.com/en/referral/fdfc0e) |
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [Регистрация](https://app.lighter.xyz/?referral=68151432) |
### AI Модели (Режим API ключей)
| AI Модель | Статус | Получить API ключ |
|:---------|:------:|:------------|
| <img src="../../../web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [Получить](https://platform.deepseek.com) |
| <img src="../../../web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [Получить](https://dashscope.console.aliyun.com) |
| <img src="../../../web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [Получить](https://platform.openai.com) |
| <img src="../../../web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [Получить](https://console.anthropic.com) |
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [Получить](https://aistudio.google.com) |
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [Получить](https://console.x.ai) |
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [Получить](https://platform.moonshot.cn) |
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [Получить](https://platform.minimaxi.com) |
### AI Модели (Режим x402 — без API ключей)
15+ моделей через [Claw402](https://claw402.ai) — только USDC кошелёк
---
<br/>
## Установка
### Linux / macOS
**Linux / macOS**
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
### Railway (Облако)
**Railway**
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/nofx?referralCode=nofx)
### Docker
**Docker**
```bash
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
docker compose -f docker-compose.prod.yml up -d
```
### Из исходников
**Windows** — установите [Docker Desktop](https://www.docker.com/products/docker-desktop/), затем:
```bash
# Требования: Go 1.21+, Node.js 18+, TA-Lib
# macOS: brew install ta-lib
# Ubuntu: sudo apt-get install libta-lib0-dev
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
go build -o nofx && ./nofx # бэкенд
cd web && npm install && npm run dev # фронтенд (новый терминал)
```powershell
curl -o docker-compose.prod.yml https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
docker compose -f docker-compose.prod.yml up -d
```
---
**Из исходников** — Go 1.21+, Node.js 18+:
## Ссылки
```bash
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
go build -o nofx && ./nofx # backend
cd web && npm install && npm run dev # frontend, in a second terminal
```
**Обновление** — повторно запустите скрипт установки; он обновит всё на месте.
<details>
<summary>Развёртывание на сервере</summary>
<br/>
**HTTP**
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
# http://YOUR_IP:3000
```
**HTTPS через Cloudflare**
1. Добавьте домен в [Cloudflare](https://dash.cloudflare.com) (бесплатный план)
2. A-запись → IP сервера, с проксированием
3. SSL/TLS → Flexible
4. `TRANSPORT_ENCRYPTION=true` в `.env`
</details>
<br/>
## Документация
| | |
|:--|:--|
| Сайт | [nofxai.com](https://nofxai.com) |
| Панель | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
| API Документация | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
| Twitter | [@nofx_official](https://x.com/nofx_official) |
| :------------------------------------------------------ | :------------------------------------ |
| [Начало работы](../../getting-started/README.md) | Гайды по развёртыванию и биржевым API |
| [Архитектура](../../architecture/README.md) | Устройство системы и индекс модулей |
| [Модуль стратегий](../../architecture/STRATEGY_MODULE.md) | Выбор монет, AI-промпты, исполнение |
| [FAQ](../../guides/faq.en.md) | Частые вопросы |
| [Устранение неполадок](../../guides/TROUBLESHOOTING.md) | Диагностика типичных проблем |
> **Предупреждение**: AI автоторговля несёт значительные риски. Рекомендуется только для обучения/исследований или тестирования малых сумм.
## Сообщество
---
[Telegram](https://t.me/nofx_dev_community) · [Twitter/X](https://x.com/vergex_ai) · [Issues](https://github.com/NoFxAiOS/nofx/issues) · [vergex.trade](https://vergex.trade) · [Живой дашборд](https://vergex.trade/explore)
## Участие в разработке
Код, документация, переводы и сообщения об ошибках — всё это приветствуется; см. [руководство для контрибьюторов](../../../CONTRIBUTING.md), [кодекс поведения](../../../CODE_OF_CONDUCT.md) и [политику безопасности](../../../SECURITY.md).
NOFX отслеживает значимые вклады и намерен вознаграждать контрибьюторов по мере роста экосистемы. Приоритетные issues имеют больший вес.
| Вклад | Вес |
| :---------------- | :----: |
| PR по закреплённым issues | ★★★★★★ |
| Код (принятые PR) | ★★★★★ |
| Исправления ошибок | ★★★★ |
| Идеи новых функций | ★★★ |
| Сообщения об ошибках | ★★ |
| Документация | ★★ |
<a href="https://github.com/NoFxAiOS/nofx/graphs/contributors">
<img src="https://contrib.rocks/image?repo=NoFxAiOS/nofx" alt="Contributors"/>
</a>
## Спонсоры
<a href="https://github.com/pjl914335852-ux"><img src="https://github.com/pjl914335852-ux.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/cat9999aaa"><img src="https://github.com/cat9999aaa.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/1733055465"><img src="https://github.com/1733055465.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/kolal2020"><img src="https://github.com/kolal2020.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/CyberFFarm"><img src="https://github.com/CyberFFarm.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/vip3001003"><img src="https://github.com/vip3001003.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/mrtluh"><img src="https://github.com/mrtluh.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/cpcp1117-source"><img src="https://github.com/cpcp1117-source.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/match-007"><img src="https://github.com/match-007.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/leiwuhen1715"><img src="https://github.com/leiwuhen1715.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/SHAOXIA1991"><img src="https://github.com/SHAOXIA1991.png" width="50" height="50" style="border-radius:50%"/></a>
[Стать спонсором](https://github.com/sponsors/NoFxAiOS)
<br/>
Если NOFX вам полезен, звезда помогает другим трейдерам его найти.
[![Star History Chart](https://api.star-history.com/svg?repos=NoFxAiOS/nofx&type=Date)](https://star-history.com/#NoFxAiOS/nofx&Date)
## Лицензия
[AGPL-3.0](../../../LICENSE)
[![Star History Chart](https://api.star-history.com/svg?repos=NoFxAiOS/nofx&type=Date)](https://star-history.com/#NoFxAiOS/nofx&Date)
<sub>Автоматическая торговля сопряжена со значительным риском. Стратегии на основе AI экспериментальны и могут терять деньги. Разумно выбирайте размер позиций, разбирайтесь в устройстве каждой площадки и никогда не торгуйте средствами, потерю которых не можете себе позволить. Полный [дисклеймер](../../../DISCLAIMER.md).</sub>

View File

@@ -1,22 +1,14 @@
<h1 align="center">NOFX</h1>
<p align="center"><strong>За підтримки <a href="https://vergex.trade">vergex.trade</a></strong></p>
<p align="center">
<strong>Ваш персональний AI торговий асистент.</strong><br/>
<strong>Будь-який ринок. Будь-яка модель. Оплата USDC, без API ключів.</strong>
<img src="../../assets/nofx-banner.svg" alt="NOFX — AI trading terminal" width="100%"/>
</p>
<p align="center">
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
</p>
<p align="center">
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
<a href="https://x402.org"><img src="https://img.shields.io/badge/x402-USDC%20Payments-2775CA?style=flat" alt="x402"></a>
<a href="https://claw402.ai"><img src="https://img.shields.io/badge/Claw402-AI%20Gateway-FF6B35?style=flat" alt="Claw402"></a>
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Stars"></a>
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Release"></a>
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-E0483B?style=flat-square&labelColor=1A1813" alt="License"></a>
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/telegram-community-E0483B?style=flat-square&labelColor=1A1813&logo=telegram&logoColor=white" alt="Telegram"></a>
</p>
<p align="center">
@@ -29,160 +21,262 @@
<a href="../vi/README.md">Tiếng Việt</a>
</p>
---
<br/>
NOFX — це **автономний** AI торговий асистент з відкритим кодом. На відміну від традиційних AI інструментів, де потрібно вручну налаштовувати моделі, керувати API ключами та підключати джерела даних — AI у NOFX **сам аналізує ринки, обирає моделі та отримує дані**. Нульове втручання людини. Ви задаєте стратегію, AI робить все інше.
NOFX — торговий термінал з відкритим кодом, у якому стратегією є мовна модель. Кожен трейдер працює в безперервному циклі — читає структуру ринку, приймає рішення, виконує його, фіксує хід міркувань — а рантайм на Go обмежує кожен ордер жорсткими ризик-лімітами, які модель не може обійти.
**Повна автономність**: AI сам вирішує, яку модель використовувати, які ринкові дані отримати, коли торгувати. Без ручного налаштування моделей. Без жонглювання API ключами різних сервісів. Просто поповніть USDC гаманець і запустіть.
Ключова відмінність: **вбудовані [x402](https://x402.org) мікроплатежі**. Без API ключів. Поповніть USDC гаманець і платіть за кожен запит. Гаманець — це ваша ідентифікація.
Трейдери компонуються вільно: будь-яка модель, будь-яка з дев'яти бірж, будь-яка стратегія. Запускайте кілька одночасно та порівнюйте їх у публічній таблиці лідерів за реалізованою дохідністю. Усе працює на вашій власній машині; облікові дані бірж зашифровані на диску й ніколи її не покидають.
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
Відкрийте **http://127.0.0.1:3000**. Готово.
Термінал відкривається за адресою `http://127.0.0.1:3000`.
---
**Перший запуск**
## Швидке демо
1. Зареєструйтеся — перший акаунт стає власником інстансу.
2. Пройдіть покроковий запуск: покладіть **$1+ USDC** (мережа Base) у гаманець для оплати AI, який він створить для вас, потім підключіть Hyperliquid і внесіть **$12+ USDC** для торгівлі.
3. Запустіть **Autopilot**. AI сканує ринок кожні кілька хвилин і торгує самостійно; кожне рішення з'являється на дашборді в момент ухвалення. Зупинити його можна будь-коли одним кліком.
<p align="center">
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
<img src="../../../screenshots/demo-cover.png" alt="Відео швидкого демо NOFX" width="900"/>
</a>
</p>
<br/>
<p align="center">
Натисніть на зображення обкладинки, щоб переглянути демо-відео.
</p>
## Реєстрація на біржах
---
NOFX безкоштовний і має відкритий код. Відкриття акаунта за партнерськими посиланнями нижче дає знижку на торгові комісії та фінансує подальшу розробку.
## Як працює x402
Традиційний процес: реєстрація → купівля кредитів → отримання API ключа → управління квотою → ротація ключів.
x402 процес:
```
Запит → 402 (ось ціна) → гаманець підписує USDC → повтор → готово
```
Без акаунтів. Без API ключів. Без передоплати. Один гаманець, усі моделі.
### Вбудовані x402 провайдери
| Провайдер | Мережа | Моделі |
|:---------|:------|:-------|
| <img src="../../../web/public/icons/claw402.png" width="20" height="20" style="vertical-align: middle;"/> **[Claw402](https://claw402.ai)** | Base | GPT-5.4, Claude Opus, DeepSeek, Qwen, Grok, Gemini, Kimi — 15+ моделей |
---
## Можливості
| Функція | Опис |
|:--------|:------------|
| **Мульти-AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — перемикання будь-коли |
| **Мульти-біржа** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
| **Студія стратегій** | Візуальний конструктор — джерела монет, індикатори, контроль ризиків |
| **AI Арена дебатів** | Кілька AI обговорюють угоди, голосують, виконують |
| **AI Змагання** | AI змагаються в реальному часі, рейтинг у таблиці лідерів |
| **Telegram Агент** | Чат з торговим асистентом — стрімінг, виклик інструментів, пам'ять |
| **Лабораторія бектесту** | Історична симуляція з кривою капіталу та метриками |
| **Панель управління** | Позиції в реальному часі, P/L, логи AI рішень з Chain of Thought |
### Ринки
Криптовалюта · Акції США · Форекс · Метали
### Біржі (CEX)
| Біржа | Статус | Реєстрація (знижка) |
|:---------|:------:|:------------------------|
| Біржа | Статус | Реєстрація зі знижкою на комісії |
| :---------------------------------------------------------------------------------------------------------------------------- | :----: | :---------------------------------------------------------------------------------- |
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [Реєстрація](https://www.binance.com/join?ref=NOFXENG) |
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Реєстрація](https://partner.bybit.com/b/83856) |
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [Реєстрація](https://www.okx.com/join/1865360) |
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Реєстрація](https://app.hyperliquid.xyz/join/AITRADING) |
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [Реєстрація](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [Реєстрація](https://www.kucoin.com/r/broker/CXEV7XKK) |
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [Реєстрація](https://www.gatenode.xyz/share/VQBGUAxY) |
### Біржі (Perp-DEX)
| Біржа | Статус | Реєстрація (знижка) |
|:---------|:------:|:------------------------|
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Реєстрація](https://app.hyperliquid.xyz/join/AITRADING) |
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [Реєстрація](https://www.asterdex.com/en/referral/fdfc0e) |
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [Реєстрація](https://www.asterdex.com/en/referral/fdfc0e) |
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [Реєстрація](https://app.lighter.xyz/?referral=68151432) |
### AI Моделі (Режим API ключів)
<br/>
| AI Модель | Статус | Отримати API ключ |
|:---------|:------:|:------------|
| <img src="../../../web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [Отримати](https://platform.deepseek.com) |
| <img src="../../../web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [Отримати](https://dashscope.console.aliyun.com) |
| <img src="../../../web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [Отримати](https://platform.openai.com) |
| <img src="../../../web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [Отримати](https://console.anthropic.com) |
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [Отримати](https://aistudio.google.com) |
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [Отримати](https://console.x.ai) |
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [Отримати](https://platform.moonshot.cn) |
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [Отримати](https://platform.minimaxi.com) |
## Демо
### AI Моделі (Режим x402 — без API ключів)
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505
15+ моделей через [Claw402](https://claw402.ai) — лише USDC гаманець
<br/>
---
## Модель пропонує. Рантайм вирішує.
Рішення приймає мовна модель, що читає стек даних [Claw402.ai](https://claw402.ai) · Vergex: живу сигнальну панель, яка ранжує кожен ринок за напрямком і силою сигналу, глибокі сигнали Signal Lab по кожному інструменту, теплові карти собівартості та ліквідацій, що показують, де в натовпу «паливо» і «стіни», та чистий потік коштів у реальному часі — усе це звіряється із сирими свічками та власним живим трек-рекордом трейдера. Виконання — ні.
Кожен ордер проходить через ліміти, зашиті в код поза досяжністю моделі:
| | |
| :----------------------- | :--------------------------------------------------------------------------------- |
| Ліміти позицій | Максимум одночасних позицій, номінал обмежено часткою від капіталу, одна позиція на символ |
| Обмеження плеча | Жорсткі стелі застосовуються під час розрахунку розміру ордера, незалежно від запиту моделі |
| Захист на боці біржі | Стоп-лос і тейк-профіт розміщуються на біржі одразу після кожного входу |
| Автозакриття за просадкою | Прибуткові позиції, що віддають надто багато від свого піку, закриваються |
| Обмеження частоти угод | Мінімальний час утримання, кулдауни на повторний вхід за символом, ліміти входів на цикл і на годину |
| Безпечний режим | Повторювані збої моделі блокують нові входи, доки модель не відновиться |
| Передстартова перевірка | Доступ до моделі, кошти в гаманці, стратегія та баланси бірж перевіряються, перш ніж трейдер зможе стартувати |
Кожне рішення зберігається разом із повним ходом міркувань моделі. Жодної позиції без документального сліду.
<br/>
## Термінал
| | |
| :--- | :--- |
| **Autopilot** | Покроковий запуск: поповнення, підключення, депозит, старт — із серверною передстартовою перевіркою на кожному кроці |
| **Strategy Studio** | Пресети стилів, набори монет, індикатори, плече, впевненість входу, власні промпти |
| **Змагання** | Публічна таблиця лідерів за реалізованою дохідністю; кожен запис прив'язаний до своєї моделі |
| **Дашборд** | Живі позиції, ордери, статистика та обґрунтування кожного рішення |
<details>
<summary>Скріншоти</summary>
<br/>
| Огляд | Графік ринку |
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
| <img src="../../../screenshots/dashboard-page.png" width="400"/> | <img src="../../../screenshots/dashboard-market-chart.png" width="400"/> |
| Статистика торгівлі | Історія позицій |
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
| <img src="../../../screenshots/dashboard-trading-stats.png" width="400"/> | <img src="../../../screenshots/dashboard-position-history.png" width="400"/> |
| Редактор стратегій | Налаштування індикаторів |
| :------------------------------------------------------: | :----------------------------------------------------------: |
| <img src="../../../screenshots/strategy-studio.png" width="400"/> | <img src="../../../screenshots/strategy-indicators.png" width="400"/> |
| Змагання | Конфігурація |
| :-------------------------------------------------------: | :-----------------------------------------------------------: |
| <img src="../../../screenshots/competition-page.png" width="400"/> | <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> |
</details>
<br/>
## Моделі
Вісім провайдерів із вашими власними ключами — DeepSeek, OpenAI, Claude, Qwen, Gemini, Grok, Kimi, MiniMax — включно з власними ендпоінтами та назвами моделей.
Або взагалі без ключів: [Claw402](https://claw402.ai) тарифікує використання моделей за кожен виклик у USDC через протокол x402. Гаманець у мережі Base замінює всі API-ключі.
| Провайдер | Доступ |
| :------- | :----- |
| **Claw402** | [AI-моделі з оплатою за використання та офіційною знижкою](https://claw402.ai) |
## Ринки
Криптовалютні безстрокові контракти на всіх дев'яти біржах. На Hyperliquid той самий рантайм також торгує токенізованими акціями США, сировинними товарами, індексами, валютами та pre-IPO перпетуалами — TSLA, NVDA, GOLD, SPX, EUR, OPENAI — поряд із криптою.
<br/>
## Архітектура
```
┌─────────────────────────────────────────────────┐
│ Trading Terminal │
│ React · TypeScript · TradingView │
│ Dashboard · Strategy Studio · Competition │
├─────────────────────────────────────────────────┤
│ API Server (Go) │
│ JWT auth · encrypted credential store │
├──────────────┬──────────────┬───────────────────┤
│ Strategy │ Autopilot │ Trader Runtime │
│ Engine │ Preflight │ Risk Engine │
├──────────────┴──────────────┴───────────────────┤
│ AI Model Layer │
│ DeepSeek · OpenAI · Claude · Qwen · Gemini │
│ Grok · Kimi · MiniMax · Claw402 (x402 USDC) │
├─────────────────────────────────────────────────┤
│ Exchange Connectivity │
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
│ KuCoin · Gate · Aster · Lighter │
└─────────────────────────────────────────────────┘
```
<br/>
## Встановлення
### Linux / macOS
**Linux / macOS**
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
### Railway (Хмара)
**Railway**
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/nofx?referralCode=nofx)
### Docker
**Docker**
```bash
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
docker compose -f docker-compose.prod.yml up -d
```
### З вихідного коду
**Windows** — встановіть [Docker Desktop](https://www.docker.com/products/docker-desktop/), потім:
```bash
# Вимоги: Go 1.21+, Node.js 18+, TA-Lib
# macOS: brew install ta-lib
# Ubuntu: sudo apt-get install libta-lib0-dev
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
go build -o nofx && ./nofx # бекенд
cd web && npm install && npm run dev # фронтенд (новий термінал)
```powershell
curl -o docker-compose.prod.yml https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
docker compose -f docker-compose.prod.yml up -d
```
---
**З вихідного коду** — Go 1.21+, Node.js 18+:
## Посилання
```bash
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
go build -o nofx && ./nofx # backend
cd web && npm install && npm run dev # frontend, in a second terminal
```
**Оновлення** — запустіть інсталяційний скрипт ще раз; він оновить усе на місці.
<details>
<summary>Розгортання на сервері</summary>
<br/>
**HTTP**
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
# http://YOUR_IP:3000
```
**HTTPS через Cloudflare**
1. Додайте домен у [Cloudflare](https://dash.cloudflare.com) (безкоштовний план)
2. A-запис → IP сервера, проксійований
3. SSL/TLS → Flexible
4. `TRANSPORT_ENCRYPTION=true` у `.env`
</details>
<br/>
## Документація
| | |
|:--|:--|
| Сайт | [nofxai.com](https://nofxai.com) |
| Панель | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
| API Документація | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
| Twitter | [@nofx_official](https://x.com/nofx_official) |
| :------------------------------------------------------ | :------------------------------------ |
| [Початок роботи](../../getting-started/README.md) | Посібники з розгортання та біржових API |
| [Архітектура](../../architecture/README.md) | Дизайн системи та індекс модулів |
| [Модуль стратегій](../../architecture/STRATEGY_MODULE.md) | Вибір монет, AI-промпти, виконання |
| [FAQ](../../guides/faq.en.md) | Поширені запитання |
| [Усунення несправностей](../../guides/TROUBLESHOOTING.md) | Діагностика типових проблем |
> **Попередження**: AI автоторгівля несе значні ризики. Рекомендується лише для навчання/досліджень або тестування малих сум.
## Спільнота
---
[Telegram](https://t.me/nofx_dev_community) · [Twitter/X](https://x.com/vergex_ai) · [Issues](https://github.com/NoFxAiOS/nofx/issues) · [vergex.trade](https://vergex.trade) · [Живий дашборд](https://vergex.trade/explore)
## Участь у розробці
Код, документація, переклади та звіти про помилки — усе це вітається; див. [Посібник для контриб'юторів](../../../CONTRIBUTING.md), [Кодекс поведінки](../../../CODE_OF_CONDUCT.md) і [Політику безпеки](../../../SECURITY.md).
NOFX відстежує значущі внески й має намір винагороджувати контриб'юторів у міру зростання екосистеми. Пріоритетні issues мають більшу вагу.
| Внесок | Вага |
| :---------------- | :----: |
| PR до закріплених issues | ★★★★★★ |
| Код (змерджені PR) | ★★★★★ |
| Виправлення багів | ★★★★ |
| Ідеї функцій | ★★★ |
| Звіти про баги | ★★ |
| Документація | ★★ |
<a href="https://github.com/NoFxAiOS/nofx/graphs/contributors">
<img src="https://contrib.rocks/image?repo=NoFxAiOS/nofx" alt="Contributors"/>
</a>
## Спонсори
<a href="https://github.com/pjl914335852-ux"><img src="https://github.com/pjl914335852-ux.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/cat9999aaa"><img src="https://github.com/cat9999aaa.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/1733055465"><img src="https://github.com/1733055465.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/kolal2020"><img src="https://github.com/kolal2020.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/CyberFFarm"><img src="https://github.com/CyberFFarm.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/vip3001003"><img src="https://github.com/vip3001003.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/mrtluh"><img src="https://github.com/mrtluh.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/cpcp1117-source"><img src="https://github.com/cpcp1117-source.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/match-007"><img src="https://github.com/match-007.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/leiwuhen1715"><img src="https://github.com/leiwuhen1715.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/SHAOXIA1991"><img src="https://github.com/SHAOXIA1991.png" width="50" height="50" style="border-radius:50%"/></a>
[Стати спонсором](https://github.com/sponsors/NoFxAiOS)
<br/>
Якщо NOFX вам корисний, зірка допоможе іншим трейдерам його знайти.
[![Star History Chart](https://api.star-history.com/svg?repos=NoFxAiOS/nofx&type=Date)](https://star-history.com/#NoFxAiOS/nofx&Date)
## Ліцензія
[AGPL-3.0](../../../LICENSE)
[![Star History Chart](https://api.star-history.com/svg?repos=NoFxAiOS/nofx&type=Date)](https://star-history.com/#NoFxAiOS/nofx&Date)
<sub>Автоматизована торгівля пов'язана зі значним ризиком. Стратегії на основі AI експериментальні й можуть втрачати гроші. Обирайте розмір позицій розважливо, розумійте кожен майданчик і ніколи не торгуйте коштами, втрату яких не можете собі дозволити. Повний [дисклеймер](../../../DISCLAIMER.md).</sub>

View File

@@ -1,22 +1,14 @@
<h1 align="center">NOFX</h1>
<p align="center"><strong>Được hậu thuẫn bởi <a href="https://vergex.trade">vergex.trade</a></strong></p>
<p align="center">
<strong>Trợ lý giao dịch AI cá nhân của bạn.</strong><br/>
<strong>Mọi thị trường. Mọi mô hình. Thanh toán USDC, không cần API key.</strong>
<img src="../../assets/nofx-banner.svg" alt="NOFX — AI trading terminal" width="100%"/>
</p>
<p align="center">
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
</p>
<p align="center">
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
<a href="https://x402.org"><img src="https://img.shields.io/badge/x402-USDC%20Payments-2775CA?style=flat" alt="x402"></a>
<a href="https://claw402.ai"><img src="https://img.shields.io/badge/Claw402-AI%20Gateway-FF6B35?style=flat" alt="Claw402"></a>
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Stars"></a>
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Release"></a>
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-E0483B?style=flat-square&labelColor=1A1813" alt="License"></a>
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/telegram-community-E0483B?style=flat-square&labelColor=1A1813&logo=telegram&logoColor=white" alt="Telegram"></a>
</p>
<p align="center">
@@ -29,158 +21,262 @@
<a href="README.md">Tiếng Việt</a>
</p>
---
<br/>
NOFX là trợ lý giao dịch AI **tự chủ** mã nguồn mở. Không giống các công cụ AI truyền thống yêu cầu bạn cấu hình mô hình thủ công, quản lý API key và kết nối nguồn dữ liệu — AI của NOFX **tự nhận diện thị trường, tự chọn mô hình và tự lấy dữ liệu**. Không cần con người can thiệp. Bạn chỉ cần đặt chiến lược, AI xử lý mọi thứ còn lại.
NOFX là một terminal giao dịch mã nguồn mở, nơi chiến lược chính là một mô hình ngôn ngữ. Mỗi trader chạy một vòng lặp liên tục — đọc cấu trúc thị trường, ra quyết định, thực thi, ghi lại lập luận — trong khi một runtime viết bằng Go ép mọi lệnh vào các giới hạn rủi ro cứng mà mô hình không thể vượt qua.
**Hoàn toàn tự chủ**: AI tự quyết định sử dụng mô hình nào, lấy dữ liệu thị trường gì, khi nào giao dịch. Không cần cấu hình mô hình thủ công. Không cần quản lý API key của nhiều dịch vụ. Chỉ cần nạp ví USDC và chạy.
Điểm khác biệt: **tích hợp thanh toán vi mô [x402](https://x402.org)**. Không cần API key. Nạp ví USDC và thanh toán theo yêu cầu. Ví chính là danh tính của bạn.
Trader được kết hợp tự do: bất kỳ mô hình nào, bất kỳ sàn nào trong chín sàn, bất kỳ chiến lược nào. Chạy nhiều trader song song và so sánh chúng trên bảng xếp hạng công khai theo lợi nhuận đã thực hiện. Mọi thứ chạy trên máy của chính bạn; thông tin xác thực sàn được mã hóa khi lưu trữ và không bao giờ rời khỏi máy.
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
Mở **http://127.0.0.1:3000**. Xong.
Terminal mở tại `http://127.0.0.1:3000`.
---
**Lần chạy đầu tiên**
## Demo nhanh
1. Đăng ký — tài khoản đầu tiên trở thành chủ sở hữu của instance.
2. Làm theo quy trình khởi chạy có hướng dẫn: nạp **$1+ USDC** (mạng Base) vào ví phí AI được tạo sẵn cho bạn, sau đó kết nối Hyperliquid và nạp **$12+ USDC** để giao dịch.
3. Khởi động **Autopilot**. AI quét thị trường vài phút một lần và tự mình giao dịch; mọi quyết định hiện lên dashboard ngay khi diễn ra. Dừng lại bất cứ lúc nào chỉ với một cú nhấp.
<p align="center">
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
<img src="../../../screenshots/demo-cover.png" alt="Video demo nhanh của NOFX" width="900"/>
</a>
</p>
<br/>
<p align="center">
Nhấp vào ảnh bìa để xem video demo.
</p>
## Đăng ký sàn giao dịch
---
NOFX miễn phí và mã nguồn mở. Mở tài khoản qua các liên kết đối tác bên dưới sẽ được giảm phí giao dịch và góp phần tài trợ cho việc phát triển lâu dài.
## x402 hoạt động như thế nào
Quy trình truyền thống: đăng ký tài khoản → mua credits → lấy API key → quản lý quota → xoay key.
Quy trình x402:
```
Yêu cầu → 402 (đây là giá) → ví ký USDC → thử lại → xong
```
Không tài khoản. Không API key. Không trả trước. Một ví, tất cả mô hình.
### Nhà cung cấp x402 tích hợp
| Nhà cung cấp | Chain | Mô hình |
|:---------|:------|:-------|
| <img src="../../../web/public/icons/claw402.png" width="20" height="20" style="vertical-align: middle;"/> **[Claw402](https://claw402.ai)** | Base | GPT-5.4, Claude Opus, DeepSeek, Qwen, Grok, Gemini, Kimi — 15+ mô hình |
---
## Tính năng
| Tính năng | Mô tả |
|:--------|:------------|
| **Đa AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — chuyển đổi bất cứ lúc nào |
| **Đa Sàn** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
| **Strategy Studio** | Trình xây dựng trực quan — nguồn coin, chỉ báo, kiểm soát rủi ro |
| **AI Competition** | AI cạnh tranh thời gian thực, bảng xếp hạng hiệu suất |
| **Telegram Agent** | Chat với trợ lý giao dịch — streaming, gọi công cụ, bộ nhớ |
| **Dashboard** | Vị thế trực tiếp, P/L, nhật ký quyết định AI với Chain of Thought |
### Thị trường
Crypto · Cổ phiếu Mỹ · Forex · Kim loại
### Sàn giao dịch (CEX)
| Sàn | Trạng thái | Đăng ký (Giảm phí) |
|:---------|:------:|:------------------------|
| Sàn giao dịch | Trạng thái | Đăng ký kèm ưu đãi phí |
| :---------------------------------------------------------------------------------------------------------------------------- | :----: | :---------------------------------------------------------------------------------- |
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [Đăng ký](https://www.binance.com/join?ref=NOFXENG) |
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Đăng ký](https://partner.bybit.com/b/83856) |
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [Đăng ký](https://www.okx.com/join/1865360) |
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Đăng ký](https://app.hyperliquid.xyz/join/AITRADING) |
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [Đăng ký](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [Đăng ký](https://www.kucoin.com/r/broker/CXEV7XKK) |
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [Đăng ký](https://www.gatenode.xyz/share/VQBGUAxY) |
### Sàn giao dịch (Perp-DEX)
| Sàn | Trạng thái | Đăng ký (Giảm phí) |
|:---------|:------:|:------------------------|
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Đăng ký](https://app.hyperliquid.xyz/join/AITRADING) |
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [Đăng ký](https://www.asterdex.com/en/referral/fdfc0e) |
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [Đăng ký](https://www.asterdex.com/en/referral/fdfc0e) |
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [Đăng ký](https://app.lighter.xyz/?referral=68151432) |
### Mô hình AI (Chế độ API Key)
<br/>
| Mô hình AI | Trạng thái | Lấy API Key |
|:---------|:------:|:------------|
| <img src="../../../web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [Lấy API Key](https://platform.deepseek.com) |
| <img src="../../../web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [Lấy API Key](https://dashscope.console.aliyun.com) |
| <img src="../../../web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [Lấy API Key](https://platform.openai.com) |
| <img src="../../../web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [Lấy API Key](https://console.anthropic.com) |
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [Lấy API Key](https://aistudio.google.com) |
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [Lấy API Key](https://console.x.ai) |
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [Lấy API Key](https://platform.moonshot.cn) |
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [Lấy API Key](https://platform.minimaxi.com) |
## Demo
### Mô hình AI (Chế độ x402 — Không cần API Key)
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505
15+ mô hình qua [Claw402](https://claw402.ai) — chỉ cần ví USDC
<br/>
---
## Mô hình đề xuất. Runtime định đoạt.
Các quyết định đến từ một mô hình ngôn ngữ đọc bộ dữ liệu [Claw402.ai](https://claw402.ai) · Vergex: bảng tín hiệu trực tiếp xếp hạng mọi thị trường theo thiên hướng và độ mạnh tín hiệu, tín hiệu chuyên sâu Signal Lab cho từng mã, bản đồ nhiệt chi phí vốn và thanh lý cho thấy "nhiên liệu" và "bức tường" của đám đông nằm ở đâu, cùng dòng tiền ròng theo thời gian thực — tất cả được đối chiếu với nến thô và thành tích thực chiến của chính trader. Việc thực thi thì không.
Mọi lệnh đều phải đi qua các giới hạn được thực thi bằng code, nằm ngoài tầm với của mô hình:
| | |
| :----------------------- | :--------------------------------------------------------------------------------- |
| Giới hạn vị thế | Số vị thế đồng thời tối đa, giá trị danh nghĩa bị chặn theo tỷ lệ vốn, mỗi symbol chỉ một vị thế |
| Kẹp đòn bẩy | Trần cứng áp dụng ngay khi tính khối lượng lệnh, bất kể mô hình yêu cầu gì |
| Bảo vệ phía sàn | Stop-loss và take-profit được đặt trên sàn ngay sau mỗi lần vào lệnh |
| Tự đóng khi rút vốn từ đỉnh | Vị thế đang lãi nhưng để mất quá nhiều so với đỉnh sẽ bị đóng |
| Điều tiết giao dịch | Thời gian giữ tối thiểu, thời gian chờ vào lại theo từng symbol, giới hạn số lần vào lệnh theo chu kỳ và theo giờ |
| Chế độ an toàn | Mô hình lỗi liên tiếp sẽ bị chặn vào lệnh mới cho đến khi mô hình hồi phục |
| Kiểm tra tiền khởi chạy | Quyền truy cập mô hình, tiền trong ví, chiến lược và số dư sàn được xác minh trước khi trader được phép khởi động |
Mỗi quyết định được lưu kèm toàn bộ lập luận của mô hình. Không có vị thế nào thiếu hồ sơ lưu vết.
<br/>
## Terminal
| | |
| :--- | :--- |
| **Autopilot** | Khởi chạy có hướng dẫn: nạp phí, kết nối, nạp tiền, khởi động — với kiểm tra preflight phía máy chủ xuyên suốt |
| **Strategy Studio** | Preset phong cách, tập coin, chỉ báo, đòn bẩy, độ tin cậy vào lệnh, prompt tùy chỉnh |
| **Cạnh tranh** | Bảng xếp hạng công khai theo lợi nhuận đã thực hiện, mỗi mục được ghi nhận cho mô hình tạo ra nó |
| **Dashboard** | Vị thế trực tiếp, lệnh, thống kê và lập luận đằng sau mỗi quyết định |
<details>
<summary>Ảnh chụp màn hình</summary>
<br/>
| Tổng quan | Biểu đồ thị trường |
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
| <img src="../../../screenshots/dashboard-page.png" width="400"/> | <img src="../../../screenshots/dashboard-market-chart.png" width="400"/> |
| Thống kê giao dịch | Lịch sử vị thế |
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
| <img src="../../../screenshots/dashboard-trading-stats.png" width="400"/> | <img src="../../../screenshots/dashboard-position-history.png" width="400"/> |
| Trình soạn chiến lược | Cấu hình chỉ báo |
| :------------------------------------------------------: | :----------------------------------------------------------: |
| <img src="../../../screenshots/strategy-studio.png" width="400"/> | <img src="../../../screenshots/strategy-indicators.png" width="400"/> |
| Cạnh tranh | Cấu hình |
| :-------------------------------------------------------: | :-----------------------------------------------------------: |
| <img src="../../../screenshots/competition-page.png" width="400"/> | <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> |
</details>
<br/>
## Mô hình
Tám nhà cung cấp với key của riêng bạn — DeepSeek, OpenAI, Claude, Qwen, Gemini, Grok, Kimi, MiniMax — bao gồm cả endpoint và tên mô hình tùy chỉnh.
Hoặc không cần key nào cả: [Claw402](https://claw402.ai) tính phí sử dụng mô hình theo từng lần gọi bằng USDC qua giao thức x402. Một chiếc ví trên Base thay thế mọi API key.
| Nhà cung cấp | Truy cập |
| :------- | :----- |
| **Claw402** | [Mô hình AI trả theo mức dùng với ưu đãi chính thức](https://claw402.ai) |
## Thị trường
Hợp đồng vĩnh cửu crypto trên cả chín sàn. Trên Hyperliquid, cùng một runtime còn giao dịch cổ phiếu Mỹ token hóa, hàng hóa, chỉ số, ngoại hối và hợp đồng vĩnh cửu pre-IPO — TSLA, NVDA, GOLD, SPX, EUR, OPENAI — song song với crypto.
<br/>
## Kiến trúc
```
┌─────────────────────────────────────────────────┐
│ Trading Terminal │
│ React · TypeScript · TradingView │
│ Dashboard · Strategy Studio · Competition │
├─────────────────────────────────────────────────┤
│ API Server (Go) │
│ JWT auth · encrypted credential store │
├──────────────┬──────────────┬───────────────────┤
│ Strategy │ Autopilot │ Trader Runtime │
│ Engine │ Preflight │ Risk Engine │
├──────────────┴──────────────┴───────────────────┤
│ AI Model Layer │
│ DeepSeek · OpenAI · Claude · Qwen · Gemini │
│ Grok · Kimi · MiniMax · Claw402 (x402 USDC) │
├─────────────────────────────────────────────────┤
│ Exchange Connectivity │
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
│ KuCoin · Gate · Aster · Lighter │
└─────────────────────────────────────────────────┘
```
<br/>
## Cài đặt
### Linux / macOS
**Linux / macOS**
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
### Railway (Cloud)
**Railway**
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/nofx?referralCode=nofx)
### Docker
**Docker**
```bash
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
docker compose -f docker-compose.prod.yml up -d
```
### Từ mã nguồn
**Windows** — cài [Docker Desktop](https://www.docker.com/products/docker-desktop/), sau đó:
```bash
# Yêu cầu: Go 1.21+, Node.js 18+, TA-Lib
# macOS: brew install ta-lib
# Ubuntu: sudo apt-get install libta-lib0-dev
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
go build -o nofx && ./nofx # backend
cd web && npm install && npm run dev # frontend (terminal mới)
```powershell
curl -o docker-compose.prod.yml https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
docker compose -f docker-compose.prod.yml up -d
```
---
**Từ mã nguồn** — Go 1.21+, Node.js 18+:
## Liên kết
```bash
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
go build -o nofx && ./nofx # backend
cd web && npm install && npm run dev # frontend, in a second terminal
```
**Cập nhật** — chạy lại script cài đặt; nó tự nâng cấp tại chỗ.
<details>
<summary>Triển khai lên máy chủ</summary>
<br/>
**HTTP**
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
# http://YOUR_IP:3000
```
**HTTPS qua Cloudflare**
1. Thêm domain vào [Cloudflare](https://dash.cloudflare.com) (gói miễn phí)
2. Bản ghi A → IP máy chủ, bật proxy
3. SSL/TLS → Flexible
4. `TRANSPORT_ENCRYPTION=true` trong `.env`
</details>
<br/>
## Tài liệu
| | |
|:--|:--|
| Website | [nofxai.com](https://nofxai.com) |
| Dashboard | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
| API Docs | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
| Twitter | [@nofx_official](https://x.com/nofx_official) |
| :------------------------------------------------------ | :------------------------------------ |
| [Bắt đầu](../../getting-started/README.md) | Hướng dẫn triển khai và API sàn |
| [Kiến trúc](../../architecture/README.md) | Thiết kế hệ thống và chỉ mục module |
| [Module chiến lược](../../architecture/STRATEGY_MODULE.md) | Chọn coin, prompt AI, thực thi |
| [FAQ](../../guides/faq.en.md) | Câu hỏi thường gặp |
| [Khắc phục sự cố](../../guides/TROUBLESHOOTING.md) | Chẩn đoán các sự cố thường gặp |
> **Cảnh báo rủi ro**: Giao dịch tự động AI có rủi ro đáng kể. Chỉ nên sử dụng cho mục đích học tập/nghiên cứu hoặc số tiền nhỏ.
## Cộng đồng
---
[Telegram](https://t.me/nofx_dev_community) · [Twitter/X](https://x.com/vergex_ai) · [Issues](https://github.com/NoFxAiOS/nofx/issues) · [vergex.trade](https://vergex.trade) · [Dashboard trực tiếp](https://vergex.trade/explore)
## License
## Đóng góp
Code, tài liệu, bản dịch và báo cáo lỗi đều được hoan nghênh — xem [Hướng dẫn đóng góp](../../../CONTRIBUTING.md), [Quy tắc ứng xử](../../../CODE_OF_CONDUCT.md) và [Chính sách bảo mật](../../../SECURITY.md).
NOFX theo dõi các đóng góp có ý nghĩa và dự định thưởng cho người đóng góp khi hệ sinh thái phát triển. Issue ưu tiên có trọng số cao hơn.
| Đóng góp | Trọng số |
| :---------------- | :----: |
| PR cho issue được ghim | ★★★★★★ |
| Code (PR đã merge) | ★★★★★ |
| Sửa lỗi | ★★★★ |
| Ý tưởng tính năng | ★★★ |
| Báo cáo lỗi | ★★ |
| Tài liệu | ★★ |
<a href="https://github.com/NoFxAiOS/nofx/graphs/contributors">
<img src="https://contrib.rocks/image?repo=NoFxAiOS/nofx" alt="Contributors"/>
</a>
## Nhà tài trợ
<a href="https://github.com/pjl914335852-ux"><img src="https://github.com/pjl914335852-ux.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/cat9999aaa"><img src="https://github.com/cat9999aaa.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/1733055465"><img src="https://github.com/1733055465.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/kolal2020"><img src="https://github.com/kolal2020.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/CyberFFarm"><img src="https://github.com/CyberFFarm.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/vip3001003"><img src="https://github.com/vip3001003.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/mrtluh"><img src="https://github.com/mrtluh.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/cpcp1117-source"><img src="https://github.com/cpcp1117-source.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/match-007"><img src="https://github.com/match-007.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/leiwuhen1715"><img src="https://github.com/leiwuhen1715.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/SHAOXIA1991"><img src="https://github.com/SHAOXIA1991.png" width="50" height="50" style="border-radius:50%"/></a>
[Trở thành nhà tài trợ](https://github.com/sponsors/NoFxAiOS)
<br/>
Nếu NOFX hữu ích với bạn, một ngôi sao sẽ giúp các trader khác tìm thấy nó.
[![Star History Chart](https://api.star-history.com/svg?repos=NoFxAiOS/nofx&type=Date)](https://star-history.com/#NoFxAiOS/nofx&Date)
## Giấy phép
[AGPL-3.0](../../../LICENSE)
[![Star History Chart](https://api.star-history.com/svg?repos=NoFxAiOS/nofx&type=Date)](https://star-history.com/#NoFxAiOS/nofx&Date)
<sub>Giao dịch tự động tiềm ẩn rủi ro đáng kể. Các chiến lược do AI điều khiển mang tính thử nghiệm và có thể thua lỗ. Hãy đặt kích thước vị thế hợp lý, hiểu rõ từng sàn giao dịch, và đừng bao giờ giao dịch bằng số tiền mà bạn không thể để mất. Xem đầy đủ [tuyên bố miễn trừ trách nhiệm](../../../DISCLAIMER.md).</sub>

View File

@@ -1,22 +1,14 @@
<h1 align="center">NOFX</h1>
<p align="center"><strong><a href="https://vergex.trade">vergex.trade</a> 支持</strong></p>
<p align="center">
<strong>你的个人 AI 交易助手。</strong><br/>
<strong>任何市场。任何模型。用 USDC 付费,无需 API Key。</strong>
<img src="../../assets/nofx-banner.svg" alt="NOFX — AI trading terminal" width="100%"/>
</p>
<p align="center">
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
</p>
<p align="center">
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
<a href="https://x402.org"><img src="https://img.shields.io/badge/x402-USDC%20Payments-2775CA?style=flat" alt="x402"></a>
<a href="https://claw402.ai"><img src="https://img.shields.io/badge/Claw402-AI%20Gateway-FF6B35?style=flat" alt="Claw402"></a>
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Stars"></a>
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Release"></a>
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-E0483B?style=flat-square&labelColor=1A1813" alt="License"></a>
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/telegram-community-E0483B?style=flat-square&labelColor=1A1813&logo=telegram&logoColor=white" alt="Telegram"></a>
</p>
<p align="center">
@@ -29,224 +21,262 @@
<a href="../vi/README.md">Tiếng Việt</a>
</p>
> **语言声明:** 本中文版本文档仅为方便海外华人社区阅读而提供,不代表本软件面向中国大陆、香港、澳门或台湾地区用户开放。如您位于上述地区,请勿使用本软件。
<br/>
---
NOFX 是一个开源交易终端,策略本身就是一个语言模型。每个交易员运行一个持续循环——读取市场结构、做出决策、执行、记录推理过程——同时由 Go 运行时把每一笔订单钳制在模型无法越过的硬性风控限制之内。
NOFX 是一个开源的**自主式** AI 交易助手。与需要手动配置模型、管理 API Key、接入数据源的传统 AI 工具不同 —— NOFX 的 AI **自主感知市场、自选模型、自动获取数据**。零人工干预。你只需设定策略AI 负责一切
**完全自主**AI 自行决定使用哪个模型、获取什么市场数据、何时交易。无需手动配置模型,无需管理各种服务的 API Key。只需充值 USDC 钱包,一键启动。
核心差异:**内置 [x402](https://x402.org) 微支付协议**。无需 API Key充值 USDC 钱包即可按需付费。钱包就是你的身份。
交易员可以自由组合:任意模型、九家交易所任选、任意策略。多个交易员并行运行,在公开排行榜上按已实现收益一较高下。一切都在你自己的机器上运行;交易所凭证加密存储,绝不外传
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
打开 **http://127.0.0.1:3000**,完成
终端会在 `http://127.0.0.1:3000` 打开
---
**首次运行**
## 快速演示
1. 注册——第一个账户即成为该实例的所有者。
2. 按引导式启动流程操作:向系统为你创建的 AI 费用钱包存入 **$1+ USDC**Base 网络),然后连接 Hyperliquid 并存入 **$12+ USDC** 作为交易资金。
3. 启动 **Autopilot**。AI 每隔几分钟扫描一次市场并自主交易;每一个决策都会实时出现在仪表板上。随时可以一键停止。
<p align="center">
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
<img src="../../../screenshots/demo-cover.png" alt="NOFX 快速演示视频" width="900"/>
</a>
</p>
<br/>
<p align="center">
点击封面图即可观看 Demo 视频。
</p>
## 注册交易所
---
NOFX 免费且开源。通过下方合作伙伴链接开户可享受更低的交易手续费,同时也为项目的持续开发提供支持。
## x402 如何工作
传统流程:注册账号 → 购买额度 → 获取 API Key → 管理配额 → 轮换密钥。
x402 流程:
```
请求 → 402返回价格→ 钱包签名 USDC → 重试 → 完成
```
无需注册。无需 API Key。无需预付费。一个钱包所有模型。
### 内置 x402 提供商
| 提供商 | 链 | 模型 |
|:---------|:------|:-------|
| <img src="../../../web/public/icons/claw402.png" width="20" height="20" style="vertical-align: middle;"/> **[Claw402](https://claw402.ai)** | Base | GPT-5.4、Claude Opus、DeepSeek、Qwen、Grok、Gemini、Kimi — 15+ 模型 |
---
## 功能概览
| 功能 | 描述 |
|:--------|:------------|
| **多 AI** | DeepSeek、Qwen、GPT、Claude、Gemini、Grok、Kimi、MiniMax — 随时切换 |
| **多交易所** | Binance、Bybit、OKX、Bitget、KuCoin、Gate、Hyperliquid、Aster、Lighter |
| **策略工作室** | 可视化构建器 — 币种来源、指标、风控 |
| **AI 竞赛** | AI 实时竞争,排行榜排名 |
| **Telegram Agent** | 与交易助手对话 — 流式输出、工具调用、记忆 |
| **回测实验室** | 历史模拟,权益曲线和性能指标 |
| **仪表板** | 实时持仓、盈亏、AI 决策日志与思维链 |
### 市场
加密货币 · 美股 · 外汇 · 贵金属
### 交易所 (CEX)
| 交易所 | 状态 | 注册 (手续费折扣) |
|:---------|:------:|:------------------------|
| 交易所 | 状态 | 享手续费折扣注册 |
| :---------------------------------------------------------------------------------------------------------------------------- | :----: | :---------------------------------------------------------------------------------- |
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [注册](https://www.binance.com/join?ref=NOFXENG) |
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [注册](https://partner.bybit.com/b/83856) |
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [注册](https://www.okx.com/join/1865360) |
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [注册](https://app.hyperliquid.xyz/join/AITRADING) |
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [注册](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [注册](https://www.kucoin.com/r/broker/CXEV7XKK) |
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [注册](https://www.gatenode.xyz/share/VQBGUAxY) |
### 交易所 (Perp-DEX)
| 交易所 | 状态 | 注册 (手续费折扣) |
|:---------|:------:|:------------------------|
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [注册](https://app.hyperliquid.xyz/join/AITRADING) |
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [注册](https://www.asterdex.com/en/referral/fdfc0e) |
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [注册](https://www.asterdex.com/en/referral/fdfc0e) |
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [注册](https://app.lighter.xyz/?referral=68151432) |
### AI 模型 (API Key 模式)
<br/>
| AI 模型 | 状态 | 获取 API Key |
|:---------|:------:|:------------|
| <img src="../../../web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [获取 API Key](https://platform.deepseek.com) |
| <img src="../../../web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **通义千问** | ✅ | [获取 API Key](https://dashscope.console.aliyun.com) |
| <img src="../../../web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [获取 API Key](https://platform.openai.com) |
| <img src="../../../web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [获取 API Key](https://console.anthropic.com) |
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [获取 API Key](https://aistudio.google.com) |
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [获取 API Key](https://console.x.ai) |
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [获取 API Key](https://platform.moonshot.cn) |
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [获取 API Key](https://platform.minimaxi.com) |
## 演示
### AI 模型 (x402 模式 — 无需 API Key)
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505
15+ 模型通过 [Claw402](https://claw402.ai) 接入 — 只需一个 USDC 钱包
<br/>
---
## 模型提议,运行时裁决
决策来自一个读取 [Claw402.ai](https://claw402.ai) · Vergex 数据栈的语言模型实时信号看板为每个市场标注方向偏置与信号强度排名Signal Lab 提供逐标的深度信号,成本与清算热力图揭示市场的"燃料"与"墙"在哪里,再叠加实时资金净流——并与原始 K 线和交易员自身的实盘战绩交叉验证。但执行不由它说了算。
每一笔订单都要经过代码层面强制执行的限制,模型无从干预:
| | |
| :----------------------- | :--------------------------------------------------------------------------------- |
| 持仓限制 | 最大并发持仓数、名义价值按权益比例封顶、每个币种仅允许一个持仓 |
| 杠杆钳制 | 在订单定量时施加硬上限,与模型请求的杠杆无关 |
| 交易所端保护 | 每次开仓后立即在交易所挂出止损和止盈单 |
| 回撤自动平仓 | 盈利持仓从峰值回吐过多时会被自动平掉 |
| 交易节流 | 最短持仓时间、单币种再入场冷却、单周期与单小时开仓次数限制 |
| 安全模式 | 模型连续失败时阻止新开仓,直至模型恢复正常 |
| 启动预检 | 交易员启动前须通过模型访问、钱包资金、策略和交易所余额的校验 |
每个决策都连同模型的完整推理一起存档。没有任何持仓是无据可查的。
<br/>
## 终端
| | |
| :--- | :--- |
| **Autopilot** | 引导式启动:注资、连接、入金、启动——全程由服务端预检保驾护航 |
| **Strategy Studio** | 风格预设、币种池、技术指标、杠杆、开仓置信度、自定义提示词 |
| **竞赛** | 按已实现收益排名的公开排行榜,每个条目都标注所用模型 |
| **仪表板** | 实时持仓、订单、统计数据,以及每个决策背后的推理 |
<details>
<summary>截图</summary>
<br/>
| 概览 | 行情图表 |
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
| <img src="../../../screenshots/dashboard-page.png" width="400"/> | <img src="../../../screenshots/dashboard-market-chart.png" width="400"/> |
| 交易统计 | 持仓历史 |
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
| <img src="../../../screenshots/dashboard-trading-stats.png" width="400"/> | <img src="../../../screenshots/dashboard-position-history.png" width="400"/> |
| 策略编辑器 | 指标配置 |
| :------------------------------------------------------: | :----------------------------------------------------------: |
| <img src="../../../screenshots/strategy-studio.png" width="400"/> | <img src="../../../screenshots/strategy-indicators.png" width="400"/> |
| 竞赛 | 配置 |
| :-------------------------------------------------------: | :-----------------------------------------------------------: |
| <img src="../../../screenshots/competition-page.png" width="400"/> | <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> |
</details>
<br/>
## 模型
八家提供商使用你自己的密钥——DeepSeek、OpenAI、Claude、Qwen、Gemini、Grok、Kimi、MiniMax——并支持自定义端点和模型名称。
或者完全不需要密钥:[Claw402](https://claw402.ai) 通过 x402 协议以 USDC 按次计量模型用量。一个 Base 链上的钱包即可替代所有 API 密钥。
| 提供商 | 接入方式 |
| :------- | :----- |
| **Claw402** | [按量付费的 AI 模型,享官方折扣](https://claw402.ai) |
## 市场
九家交易所全部支持加密货币永续合约。在 Hyperliquid 上,同一套运行时还可以交易代币化美股、大宗商品、指数、外汇和 pre-IPO 永续合约——TSLA、NVDA、GOLD、SPX、EUR、OPENAI——与加密资产并行。
<br/>
## 架构
```
┌─────────────────────────────────────────────────┐
│ Trading Terminal │
│ React · TypeScript · TradingView │
│ Dashboard · Strategy Studio · Competition │
├─────────────────────────────────────────────────┤
│ API Server (Go) │
│ JWT auth · encrypted credential store │
├──────────────┬──────────────┬───────────────────┤
│ Strategy │ Autopilot │ Trader Runtime │
│ Engine │ Preflight │ Risk Engine │
├──────────────┴──────────────┴───────────────────┤
│ AI Model Layer │
│ DeepSeek · OpenAI · Claude · Qwen · Gemini │
│ Grok · Kimi · MiniMax · Claw402 (x402 USDC) │
├─────────────────────────────────────────────────┤
│ Exchange Connectivity │
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
│ KuCoin · Gate · Aster · Lighter │
└─────────────────────────────────────────────────┘
```
<br/>
## 安装
### Linux / macOS
**Linux / macOS**
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
### Railway (云部署)
**Railway**
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/nofx?referralCode=nofx)
### Docker
**Docker**
```bash
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
docker compose -f docker-compose.prod.yml up -d
```
### Windows
安装 [Docker Desktop](https://www.docker.com/products/docker-desktop/),然后:
**Windows** —— 安装 [Docker Desktop](https://www.docker.com/products/docker-desktop/),然后:
```powershell
curl -o docker-compose.prod.yml https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
docker compose -f docker-compose.prod.yml up -d
```
### 从源码构建
**从源码构建** —— 需要 Go 1.21+、Node.js 18+
```bash
# 前置条件: Go 1.21+, Node.js 18+, TA-Lib
# macOS: brew install ta-lib
# Ubuntu: sudo apt-get install libta-lib0-dev
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
go build -o nofx && ./nofx # 后端
cd web && npm install && npm run dev # 前端 (新终端)
go build -o nofx && ./nofx # backend
cd web && npm install && npm run dev # frontend, in a second terminal
```
### 更新
**更新** —— 重新运行安装脚本,即可原地升级。
<details>
<summary>服务器部署</summary>
<br/>
**HTTP**
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
# http://YOUR_IP:3000
```
---
**通过 Cloudflare 启用 HTTPS**
## 配置
1. 将域名添加到 [Cloudflare](https://dash.cloudflare.com)(免费套餐即可)
2. A 记录 → 服务器 IP开启代理
3. SSL/TLS → Flexible
4.`.env` 中设置 `TRANSPORT_ENCRYPTION=true`
**新手模式**:首次使用的用户可以在注册时选择新手模式,系统会引导你逐步完成 AI、交易所和策略的配置。
</details>
**进阶模式**
1. **AI** — 添加 API Key 或配置 x402 钱包
2. **交易所** — 连接交易所 API 凭证
3. **策略** — 在策略工作室构建
4. **交易员** — 组合 AI + 交易所 + 策略
5. **交易** — 从仪表板启动
所有操作通过 Web 界面完成:**http://127.0.0.1:3000**
---
<br/>
## 文档
| | |
|:--|:--|
| [架构概览](../../architecture/README.md) | 系统设计和模块索引 |
| :------------------------------------------------------ | :------------------------------------ |
| [快速开始](../../getting-started/README.zh-CN.md) | 部署与交易所 API 指南 |
| [架构](../../architecture/README.md) | 系统设计与模块索引 |
| [策略模块](../../architecture/STRATEGY_MODULE.md) | 币种选择、AI 提示词、执行 |
| [常见问题](../../faq/README.md) | FAQ |
| [快速开始](../../getting-started/README.md) | 部署指南 |
| [常见问题](../../guides/faq.zh-CN.md) | 常见疑问解答 |
| [故障排查](../../guides/TROUBLESHOOTING.zh-CN.md) | 常见问题诊断 |
---
## 社区
[Telegram](https://t.me/nofx_dev_community) · [Twitter/X](https://x.com/vergex_ai) · [Issues](https://github.com/NoFxAiOS/nofx/issues) · [vergex.trade](https://vergex.trade) · [实时仪表板](https://vergex.trade/explore)
## 贡献
查看 [贡献指南](../../../CONTRIBUTING.md) · [行为准则](../../../CODE_OF_CONDUCT.md) · [安全政策](../../../SECURITY.md)
代码、文档、翻译和 Bug 报告都欢迎——参见[贡献指南](../../../CONTRIBUTING.md)[行为准则](../../../CODE_OF_CONDUCT.md)[安全政策](../../../SECURITY.md)
### 贡献者空投计划
所有贡献在 GitHub 上追踪。当 NOFX 产生收入时,贡献者将获得空投。
**解决 [置顶 Issue](https://github.com/NoFxAiOS/nofx/issues) 的 PR 获得最高奖励!**
NOFX 会记录有价值的贡献,并计划随着生态发展回馈贡献者。优先级 Issue 拥有更高权重。
| 贡献类型 | 权重 |
|:-------------|:------:|
| 置顶 Issue PR | ★★★★★★ |
| 代码提交 (合并 PR) | ★★★★★ |
| :---------------- | :----: |
| 置顶 Issue PR | ★★★★★★ |
| 代码(已合并 PR | ★★★★★ |
| Bug 修复 | ★★★★ |
| 功能建议 | ★★★ |
| Bug 报告 | ★★ |
| 文档 | ★★ |
---
<a href="https://github.com/NoFxAiOS/nofx/graphs/contributors">
<img src="https://contrib.rocks/image?repo=NoFxAiOS/nofx" alt="Contributors"/>
</a>
## 链接
## 赞助者
| | |
|:--|:--|
| 官网 | [nofxai.com](https://nofxai.com) |
| 数据面板 | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
| API 文档 | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
| Twitter | [@nofx_official](https://x.com/nofx_official) |
<a href="https://github.com/pjl914335852-ux"><img src="https://github.com/pjl914335852-ux.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/cat9999aaa"><img src="https://github.com/cat9999aaa.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/1733055465"><img src="https://github.com/1733055465.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/kolal2020"><img src="https://github.com/kolal2020.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/CyberFFarm"><img src="https://github.com/CyberFFarm.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/vip3001003"><img src="https://github.com/vip3001003.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/mrtluh"><img src="https://github.com/mrtluh.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/cpcp1117-source"><img src="https://github.com/cpcp1117-source.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/match-007"><img src="https://github.com/match-007.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/leiwuhen1715"><img src="https://github.com/leiwuhen1715.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/SHAOXIA1991"><img src="https://github.com/SHAOXIA1991.png" width="50" height="50" style="border-radius:50%"/></a>
> **风险提示**: AI 自动交易存在重大风险。建议仅用于学习/研究或小额测试。
[成为赞助者](https://github.com/sponsors/NoFxAiOS)
---
<br/>
## License
如果 NOFX 对你有帮助,点个 Star 能让更多交易者发现它。
[![Star History Chart](https://api.star-history.com/svg?repos=NoFxAiOS/nofx&type=Date)](https://star-history.com/#NoFxAiOS/nofx&Date)
## 许可证
[AGPL-3.0](../../../LICENSE)
[![Star History Chart](https://api.star-history.com/svg?repos=NoFxAiOS/nofx&type=Date)](https://star-history.com/#NoFxAiOS/nofx&Date)
<sub>自动化交易存在重大风险。AI 驱动的策略尚处实验阶段,可能造成亏损。请合理控制仓位规模,充分了解每个交易场所,切勿投入无法承受损失的资金。完整[免责声明](../../../DISCLAIMER.md)。</sub>

91
go.mod
View File

@@ -1,129 +1,104 @@
module nofx
go 1.25.3
go 1.25.11
require (
github.com/adshao/go-binance/v2 v2.8.9
github.com/agiledragon/gomonkey/v2 v2.13.0
github.com/ethereum/go-ethereum v1.16.7
github.com/antihax/optional v1.0.0
github.com/bybit-exchange/bybit.go.api v0.0.0-20250727214011-c9347d6804d6
github.com/elliottech/lighter-go v0.0.0-20251104171447-78b9b55ebc48
github.com/ethereum/go-ethereum v1.17.3
github.com/gateio/gateapi-go/v6 v6.104.3
github.com/gin-gonic/gin v1.11.0
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9
github.com/rs/zerolog v1.34.0
github.com/sirupsen/logrus v1.9.3
github.com/sonirico/go-hyperliquid v0.26.0
github.com/sonirico/go-hyperliquid v0.36.0
github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.42.0
golang.org/x/crypto v0.51.0
golang.org/x/net v0.55.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/sqlite v1.6.0
gorm.io/gorm v1.31.1
modernc.org/sqlite v1.40.0
)
require (
filippo.io/edwards25519 v1.0.0-rc.1 // indirect
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect
github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129 // indirect
github.com/antihax/optional v1.0.0 // indirect
github.com/armon/go-radix v1.0.0 // indirect
github.com/bitly/go-simplejson v0.5.1 // indirect
github.com/bits-and-blooms/bitset v1.24.0 // indirect
github.com/blendle/zapdriver v1.3.1 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/bybit-exchange/bybit.go.api v0.0.0-20250727214011-c9347d6804d6 // indirect
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/consensys/gnark-crypto v0.19.0 // indirect
github.com/crate-crypto/go-eth-kzg v1.4.0 // indirect
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a // indirect
github.com/consensys/gnark-crypto v0.19.2 // indirect
github.com/crate-crypto/go-eth-kzg v1.5.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/elastic/go-sysinfo v1.15.4 // indirect
github.com/elastic/go-windows v1.0.2 // indirect
github.com/elliottech/lighter-go v0.0.0-20251104171447-78b9b55ebc48 // indirect
github.com/elliottech/poseidon_crypto v0.0.11 // indirect
github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect
github.com/ethereum/go-verkle v0.2.2 // indirect
github.com/fatih/color v1.16.0 // indirect
github.com/ethereum/c-kzg-4844/v2 v2.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gagliardetto/binary v0.8.0 // indirect
github.com/gagliardetto/solana-go v1.14.0 // indirect
github.com/gagliardetto/treeout v0.1.4 // indirect
github.com/gateio/gateapi-go/v6 v6.104.3 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.4 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/holiman/uint256 v1.3.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/pgx/v5 v5.9.2 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/jpillora/backoff v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.16.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/logrusorgru/aurora v2.0.3+incompatible // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/mailru/easyjson v0.9.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.32 // indirect
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 // indirect
github.com/mr-tron/base58 v1.2.0 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/procfs v0.17.0 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/sonirico/vago v0.10.0 // indirect
github.com/sonirico/vago/lol v0.0.0-20250901170347-2d1d82c510bd // indirect
github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091 // indirect
github.com/sonirico/vago v0.11.4 // indirect
github.com/sonirico/vago/lol v0.1.0 // indirect
github.com/supranational/blst v0.3.16 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/valyala/fastjson v1.6.7 // indirect
github.com/valyala/fastjson v1.6.10 // indirect
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
go.elastic.co/apm/module/apmzerolog/v2 v2.7.1 // indirect
go.elastic.co/apm/v2 v2.7.1 // indirect
go.elastic.co/apm/module/apmzerolog/v2 v2.7.2 // indirect
go.elastic.co/apm/v2 v2.7.2 // indirect
go.elastic.co/fastjson v1.5.1 // indirect
go.mongodb.org/mongo-driver v1.12.2 // indirect
go.uber.org/atomic v1.7.0 // indirect
go.uber.org/mock v0.5.0 // indirect
go.uber.org/multierr v1.6.0 // indirect
go.uber.org/ratelimit v0.2.0 // indirect
go.uber.org/zap v1.21.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/mod v0.27.0 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/term v0.35.0 // indirect
golang.org/x/text v0.29.0 // indirect
golang.org/x/time v0.9.0 // indirect
golang.org/x/tools v0.36.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/driver/postgres v1.6.0 // indirect
gorm.io/driver/sqlite v1.6.0 // indirect
gorm.io/gorm v1.31.1 // indirect
howett.net/plist v1.0.1 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect

247
go.sum
View File

@@ -1,34 +1,19 @@
filippo.io/edwards25519 v1.0.0-rc.1 h1:m0VOOB23frXZvAOK44usCgLWvtsxIoMCTBGJZlpmGfU=
filippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA=
github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8=
github.com/adshao/go-binance/v2 v2.8.7 h1:n7jkhwIHMdtd/9ZU2gTqFV15XVSbUCjyFlOUAtTd8uU=
github.com/adshao/go-binance/v2 v2.8.7/go.mod h1:XkkuecSyJKPolaCGf/q4ovJYB3t0P+7RUYTbGr+LMGM=
github.com/adshao/go-binance/v2 v2.8.9 h1:NX+4u/LgEmrjTS7OMWU+9ZgfHKFM61RPhnr9/SqWPhc=
github.com/adshao/go-binance/v2 v2.8.9/go.mod h1:XkkuecSyJKPolaCGf/q4ovJYB3t0P+7RUYTbGr+LMGM=
github.com/agiledragon/gomonkey/v2 v2.13.0 h1:B24Jg6wBI1iB8EFR1c+/aoTg7QN/Cum7YffG8KMIyYo=
github.com/agiledragon/gomonkey/v2 v2.13.0/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY=
github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129 h1:MzBOUgng9orim59UnfUTLRjMpd09C5uEVQ6RPGeCaVI=
github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129/go.mod h1:rFgpPQZYZ8vdbc+48xibu8ALc3yeyd64IhHS+PU6Yyg=
github.com/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwcg=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI=
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/bitly/go-simplejson v0.5.0 h1:6IH+V8/tVMab511d5bn4M7EwGXZf9Hj6i2xSwkNEM+Y=
github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
github.com/bitly/go-simplejson v0.5.1 h1:xgwPbetQScXt1gh9BmoJ6j9JMr3TElvuIyjR8pgdoow=
github.com/bitly/go-simplejson v0.5.1/go.mod h1:YOPVLzCfwK14b4Sff3oP1AmGhI9T9Vsg84etUnlyp+Q=
github.com/bits-and-blooms/bitset v1.24.0 h1:H4x4TuulnokZKvHLfzVRTHJfFfnHEeSYJizujEZvmAM=
github.com/bits-and-blooms/bitset v1.24.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/blendle/zapdriver v1.3.1 h1:C3dydBOWYRiOk+B8X9IVZ5IOe+7cl+tGOexN4QqHfpE=
github.com/blendle/zapdriver v1.3.1/go.mod h1:mdXfREi6u5MArG4j9fewC+FGnXaBR+T4Ox4J2u4eHCc=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bybit-exchange/bybit.go.api v0.0.0-20250727214011-c9347d6804d6 h1:41FLQtKmxWEdyjdgrAm9lZFdS0Ax2XsDxkd/fuztsyQ=
github.com/bybit-exchange/bybit.go.api v0.0.0-20250727214011-c9347d6804d6/go.mod h1:P22TFRynmYRrquJCPalKxZgIIIc9+PkC4kQPeejitsI=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
@@ -37,15 +22,12 @@ github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZw
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/consensys/gnark-crypto v0.19.0 h1:zXCqeY2txSaMl6G5wFpZzMWJU9HPNh8qxPnYJ1BL9vA=
github.com/consensys/gnark-crypto v0.19.0/go.mod h1:rT23F0XSZqE0mUA0+pRtnL56IbPxs6gp4CeRsBk4XS0=
github.com/consensys/gnark-crypto v0.19.2 h1:qrEAIXq3T4egxqiliFFoNrepkIWVEeIYwt3UL0fvS80=
github.com/consensys/gnark-crypto v0.19.2/go.mod h1:rT23F0XSZqE0mUA0+pRtnL56IbPxs6gp4CeRsBk4XS0=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/crate-crypto/go-eth-kzg v1.4.0 h1:WzDGjHk4gFg6YzV0rJOAsTK4z3Qkz5jd4RE3DAvPFkg=
github.com/crate-crypto/go-eth-kzg v1.4.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI=
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a h1:W8mUrRp6NOVl3J+MYp5kPMoUZPp7aOYHtaua31lwRHg=
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a/go.mod h1:sTwzHBvIzm2RfVCGNEBZgRyjwK40bVoun3ZnGOCafNM=
github.com/crate-crypto/go-eth-kzg v1.5.0 h1:FYRiJMJG2iv+2Dy3fi14SVGjcPteZ5HAAUe4YWlJygc=
github.com/crate-crypto/go-eth-kzg v1.5.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -65,26 +47,14 @@ github.com/elliottech/poseidon_crypto v0.0.11 h1:iX4rCg0m1XIX/7mhXVUEYUJIdQD57zN
github.com/elliottech/poseidon_crypto v0.0.11/go.mod h1:NhWxSjPGr5JXRuB2Aepl/+ZrbmUG3hvku/GarB1JR8c=
github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A=
github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s=
github.com/ethereum/c-kzg-4844/v2 v2.1.5 h1:aVtoLK5xwJ6c5RiqO8g8ptJ5KU+2Hdquf6G3aXiHh5s=
github.com/ethereum/c-kzg-4844/v2 v2.1.5/go.mod h1:u59hRTTah4Co6i9fDWtiCjTrblJv0UwsqZKCc0GfgUs=
github.com/ethereum/go-ethereum v1.16.5 h1:GZI995PZkzP7ySCxEFaOPzS8+bd8NldE//1qvQDQpe0=
github.com/ethereum/go-ethereum v1.16.5/go.mod h1:kId9vOtlYg3PZk9VwKbGlQmSACB5ESPTBGT+M9zjmok=
github.com/ethereum/go-ethereum v1.16.7 h1:qeM4TvbrWK0UC0tgkZ7NiRsmBGwsjqc64BHo20U59UQ=
github.com/ethereum/go-ethereum v1.16.7/go.mod h1:Fs6QebQbavneQTYcA39PEKv2+zIjX7rPUZ14DER46wk=
github.com/ethereum/go-verkle v0.2.2 h1:I2W0WjnrFUIzzVPwm8ykY+7pL2d4VhlsePn4j7cnFk8=
github.com/ethereum/go-verkle v0.2.2/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/ethereum/c-kzg-4844/v2 v2.1.6 h1:xQymkKCT5E2Jiaoqf3v4wsNgjZLY0lRSkZn27fRjSls=
github.com/ethereum/c-kzg-4844/v2 v2.1.6/go.mod h1:8HMkUZ5JRv4hpw/XUrYWSQNAUzhHMg2UDb/U+5m+XNw=
github.com/ethereum/go-ethereum v1.17.3 h1:Ev/sQHH+UdKZHWjuVzhu2pxhi/sXaPZl23Q+Q5LDd4Q=
github.com/ethereum/go-ethereum v1.17.3/go.mod h1:f2EhRwqewIZkGoQekywI2Y2RZAMTSavLNkD9qItFy1A=
github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY=
github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gagliardetto/binary v0.8.0 h1:U9ahc45v9HW0d15LoN++vIXSJyqR/pWw8DDlhd7zvxg=
github.com/gagliardetto/binary v0.8.0/go.mod h1:2tfj51g5o9dnvsc+fL3Jxr22MuWzYXwx9wEoN0XQ7/c=
github.com/gagliardetto/solana-go v1.14.0 h1:3WfAi70jOOjAJ0deFMjdhFYlLXATF4tOQXsDNWJtOLw=
github.com/gagliardetto/solana-go v1.14.0/go.mod h1:l/qqqIN6qJJPtxW/G1PF4JtcE3Zg2vD2EliZrr9Gn5k=
github.com/gagliardetto/treeout v0.1.4 h1:ozeYerrLCmCubo1TcIjFiOWTTGteOOHND1twdFpgwaw=
github.com/gagliardetto/treeout v0.1.4/go.mod h1:loUefvXTrlRG5rYmJmExNryyBRh8f89VZhmMOyCyqok=
github.com/gateio/gateapi-go/v6 v6.104.3 h1:JQ2+s1pG4bL+JeLQyGy9c7YLr7hxRI8g7vkAuQYl75k=
github.com/gateio/gateapi-go/v6 v6.104.3/go.mod h1:racCcjrdyOUbRDO5eCUGUiyDPrF/ZmwBj/bupPZTVLY=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
@@ -110,14 +80,12 @@ github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -136,8 +104,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
@@ -154,18 +122,10 @@ github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/compress v1.16.0 h1:iULayQNOReoYUe+1qtKOqw9CwJv3aNQu8ivo7lw1HU4=
github.com/klauspost/compress v1.16.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
@@ -176,10 +136,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8=
github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M=
github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
@@ -187,50 +145,34 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g=
github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM=
github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU=
github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8=
github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag=
github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 h1:mPMvm6X6tf4w8y7j9YIt6V9jfWhL6QlbEc7CCmeQlWk=
github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1/go.mod h1:ye2e/VUEtE2BHE+G/QcKkcLQVAEJoYRFj5VUOQatCRE=
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
@@ -238,32 +180,24 @@ github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU=
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/sonirico/go-hyperliquid v0.17.0 h1:eXYACWupwu41O1VtKw17dqe9oOLQ1A2nRElGhg5Ox+4=
github.com/sonirico/go-hyperliquid v0.17.0/go.mod h1:sH51Vsu+tPUwc95TL2MoQ8YXSewLWBEJirgzo7sZx6w=
github.com/sonirico/go-hyperliquid v0.26.0 h1:C2KjaD2R/AxH1FOPl6W1LyvAx/XUHdTQYgjb4PUcPN0=
github.com/sonirico/go-hyperliquid v0.26.0/go.mod h1:SYzazq5hqC8lI1+MgSO0aJVrf0TAfyibp5NjUqnwv2I=
github.com/sonirico/vago v0.9.0 h1:DF2OWW2Aaf1xPZmnFv79kBrHmjKX3mVvMbP08vERlKo=
github.com/sonirico/vago v0.9.0/go.mod h1:fZxV1RzMe2eaZokbbDvuyoOzG3YapzqRQoOiD9VyJH0=
github.com/sonirico/vago v0.10.0 h1:y+4Wo56tK+88a5lUwVrZUO2RRLaPcBgjI5cupKpT1Oc=
github.com/sonirico/vago v0.10.0/go.mod h1:HCfnyPHId7V+zBZ5BLfIsdHIO+ewo6+uhF1N0hxlldc=
github.com/sonirico/vago/lol v0.0.0-20250901170347-2d1d82c510bd h1:rbvNORW8/0AtH/8W/SUwUykbuh2SeQBrNgFLqYpGTWY=
github.com/sonirico/vago/lol v0.0.0-20250901170347-2d1d82c510bd/go.mod h1:pteYccB32seEf19i0TPk7DKdEZdWJ/n9K9DF8AFeXGU=
github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091 h1:RN5mrigyirb8anBEtdjtHFIufXdacyTi6i4KBfeNXeo=
github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091/go.mod h1:VlduQ80JcGJSargkRU4Sg9Xo63wZD/l8A5NC/Uo1/uU=
github.com/sonirico/go-hyperliquid v0.36.0 h1:97aNCFf7PbvXtpCh+kdpqlAmLCyu4vB/USaKL73mSB8=
github.com/sonirico/go-hyperliquid v0.36.0/go.mod h1:6UkIfvbqOPtCNcdC2TQ7vVPy5k8pqgoMucbGep4gCuY=
github.com/sonirico/vago v0.11.4 h1:KlKh5iYYxKii1bhReKDIE10LPz/GPPqAcn4EvZl4t54=
github.com/sonirico/vago v0.11.4/go.mod h1:HCfnyPHId7V+zBZ5BLfIsdHIO+ewo6+uhF1N0hxlldc=
github.com/sonirico/vago/lol v0.1.0 h1:YjI+JAQ6enMYlpoM23w6J+1b11TJ8rqPpuD2NDHdFlA=
github.com/sonirico/vago/lol v0.1.0/go.mod h1:k8CVrcWhKbPSX5821lt8L64z/DaST2TUaxiJOdPaSA0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
@@ -272,7 +206,6 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/supranational/blst v0.3.16 h1:bTDadT+3fK497EvLdWRQEjiGnUtzJ7jjIUMF0jqwYhE=
github.com/supranational/blst v0.3.16/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw=
github.com/test-go/testify v1.1.4/go.mod h1:rH7cfJo/47vWGdi4GPj16x3/t1xGOj2YxzmNQzk2ghU=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
@@ -281,128 +214,62 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ=
github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM=
github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.elastic.co/apm/module/apmzerolog/v2 v2.7.1 h1:C9+KrlqS8F4SZFu+ct0Jmv2YLmzDhWsI8htK6exd3vg=
go.elastic.co/apm/module/apmzerolog/v2 v2.7.1/go.mod h1:wXViB7paxMUrERgZrmUb+0FCqgb13Dull1JOOd8Hcj0=
go.elastic.co/apm/v2 v2.7.1 h1:OFjARuESjBsxw7wHrEAnfSVNCHGBATXSI/kPvBARY/A=
go.elastic.co/apm/v2 v2.7.1/go.mod h1:tQhBAjwh93b2leuAdzGwta/sP7Yc7QoKTSjeIHHDuog=
go.elastic.co/apm/module/apmzerolog/v2 v2.7.2 h1:JPgmhFEUDfjvIrfZdWEgkwu5H2Nzhze6GFan+qoUQYo=
go.elastic.co/apm/module/apmzerolog/v2 v2.7.2/go.mod h1:oQIxTgTMMef1FgFghymN+GCXpWhW6rpQRihV8Gjoi+w=
go.elastic.co/apm/v2 v2.7.2 h1:0blxpxOMOcpBTz034RBqvEw806y0CDJwo/ut+2wZsHA=
go.elastic.co/apm/v2 v2.7.2/go.mod h1:KJcwwsaouDzcLd8EviAO+y8yrfZzD6PhUCEg82bvLV4=
go.elastic.co/fastjson v1.5.1 h1:zeh1xHrFH79aQ6Xsw7YxixvnOdAl3OSv0xch/jRDzko=
go.elastic.co/fastjson v1.5.1/go.mod h1:WtvH5wz8z9pDOPqNYSYKoLLv/9zCWZLeejHWuvdL/EM=
go.mongodb.org/mongo-driver v1.12.2 h1:gbWY1bJkkmUB9jjZzcdhOL8O85N9H+Vvsf2yFN0RDws=
go.mongodb.org/mongo-driver v1.12.2/go.mod h1:/rGBTebI3XYboVmgz+Wv3Bcbl3aD0QF9zl6kDDw18rQ=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/ratelimit v0.2.0 h1:UQE2Bgi7p2B85uP5dC2bbRtig0C+OeNRnNEafLjsLPA=
go.uber.org/ratelimit v0.2.0/go.mod h1:YYBV4e4naJvhpitQrWJu1vCpgB7CboMe0qhltKt6mUg=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.21.0 h1:WefMeulhovoZ2sYXz7st6K0sLj7bBhpiFaud4r4zST8=
go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw=
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
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/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
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/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/dnaeon/go-vcr.v4 v4.0.5 h1:I0hpTIvD5rII+8LgYGrHMA2d4SQPoL6u7ZvJakWKsiA=
gopkg.in/dnaeon/go-vcr.v4 v4.0.5/go.mod h1:dRos81TkW9C1WJt6tTaE+uV2Lo8qJT3AG2b35+CB/nQ=
gopkg.in/dnaeon/go-vcr.v4 v4.0.6 h1:PiJkrakkmzc5s7EfBnZOnyiLwi7o7A9fwPzN0X2uwe0=
gopkg.in/dnaeon/go-vcr.v4 v4.0.6/go.mod h1:sbq5oMEcM4PXngbcNbHhzfCP9OdZodLhrbRYoyg09HY=
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=

View File

@@ -6,14 +6,17 @@ import (
"fmt"
"io"
"net/http"
"os"
"nofx/logger"
"nofx/market"
"nofx/provider/hyperliquid"
"nofx/provider/nofxos"
"nofx/provider/vergex"
"nofx/security"
"nofx/store"
"os"
"sort"
"strings"
"sync"
"time"
)
@@ -103,6 +106,7 @@ type Context struct {
MultiTFMarket map[string]map[string]*market.Data `json:"-"`
OITopDataMap map[string]*OITopData `json:"-"`
QuantDataMap map[string]*QuantData `json:"-"`
VergexDataMap map[string]*vergex.MarketAnalysis `json:"-"`
OIRankingData *nofxos.OIRankingData `json:"-"` // Market-wide OI ranking data
NetFlowRankingData *nofxos.NetFlowRankingData `json:"-"` // Market-wide fund flow ranking data
PriceRankingData *nofxos.PriceRankingData `json:"-"` // Market-wide price gainers/losers
@@ -184,6 +188,8 @@ type OIDeltaData struct {
type StrategyEngine struct {
config *store.StrategyConfig
nofxosClient *nofxos.Client
vergexClient *vergex.Client
vergexRankingCache map[string]*vergex.SignalRankItem
}
// NewStrategyEngine creates strategy execution engine.
@@ -216,14 +222,44 @@ func NewStrategyEngine(config *store.StrategyConfig, claw402WalletKey ...string)
} else {
logger.Warnf("⚠️ Failed to init claw402 data client: %v (using direct nofxos.ai)", err)
}
vergexClient, err := vergex.NewClient(claw402URL, walletKey, &logger.MCPLogger{})
if err == nil {
logger.Infof("🔗 Vergex signals routed through claw402 (%s)", claw402URL)
} else {
logger.Warnf("⚠️ Failed to init Vergex claw402 client: %v", err)
}
return &StrategyEngine{
config: config,
nofxosClient: client,
vergexClient: vergexClient,
vergexRankingCache: make(map[string]*vergex.SignalRankItem),
}
}
return &StrategyEngine{
config: config,
nofxosClient: client,
vergexRankingCache: make(map[string]*vergex.SignalRankItem),
}
}
func (e *StrategyEngine) usesHyperliquidNativeUniverse() bool {
if e == nil || e.config == nil {
return false
}
source := e.config.CoinSource
if source.SourceType == "hyper_all" || source.SourceType == "hyper_main" || source.SourceType == "hyper_rank" || source.SourceType == "vergex_signal" || source.UseHyperAll || source.UseHyperMain {
return true
}
for _, symbol := range source.StaticCoins {
if market.IsXyzDexAsset(symbol) {
return true
}
}
return false
}
// GetRiskControlConfig gets risk control configuration
func (e *StrategyEngine) GetRiskControlConfig() store.RiskControlConfig {
return e.config.RiskControl
@@ -368,6 +404,27 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
}
return e.filterExcludedCoins(coins), nil
case "hyper_rank":
coins, err := e.getHyperRankCoins(coinSource.HyperRankCategory, coinSource.HyperRankDirection, coinSource.HyperRankLimit)
if err != nil {
return nil, err
}
return e.filterExcludedCoins(coins), nil
case "vergex_signal":
coins, err := e.getVergexSignalCoins(
coinSource.VergexLimit,
coinSource.VergexMarketType,
coinSource.VergexChain,
coinSource.VergexLiqBand,
coinSource.HyperRankCategory,
coinSource.StaticCoins,
)
if err != nil {
return nil, err
}
return e.filterExcludedCoins(coins), nil
case "mixed":
if coinSource.UseAI500 {
poolCoins, err := e.getAI500Coins(coinSource.AI500Limit)
@@ -586,6 +643,297 @@ func (e *StrategyEngine) getHyperMainCoins(limit int) ([]CandidateCoin, error) {
return candidates, nil
}
func clampHyperRankLimit(limit int) int {
if limit <= 0 {
return 5
}
if limit > 10 {
return 10
}
return limit
}
func (e *StrategyEngine) getHyperRankCoins(category, direction string, limit int) ([]CandidateCoin, error) {
category = strings.ToLower(strings.TrimSpace(category))
if category == "" {
category = "stock"
}
direction = strings.ToLower(strings.TrimSpace(direction))
if direction == "" {
direction = "gainers"
}
limit = clampHyperRankLimit(limit)
ctx := context.Background()
var ranked []struct {
symbol string
info hyperliquid.CoinInfo
cat string
}
if category == "crypto" || category == "all" {
coins, err := hyperliquid.GetPerpDexCoins(ctx, "")
if err != nil {
return nil, fmt.Errorf("failed to get Hyperliquid crypto ranking: %w", err)
}
for _, coin := range coins {
ranked = append(ranked, struct {
symbol string
info hyperliquid.CoinInfo
cat string
}{symbol: market.Normalize(coin.Symbol + "USDT"), info: coin, cat: "crypto"})
}
}
if category != "crypto" {
coins, err := hyperliquid.GetPerpDexCoins(ctx, "xyz")
if err != nil {
return nil, fmt.Errorf("failed to get Hyperliquid XYZ ranking: %w", err)
}
for _, coin := range coins {
base := strings.TrimPrefix(coin.Symbol, "xyz:")
cat := hyperliquid.XYZCategory(base)
if category != "all" && cat != category {
continue
}
ranked = append(ranked, struct {
symbol string
info hyperliquid.CoinInfo
cat string
}{symbol: hyperliquid.FormatCoinForAPI("xyz:" + base), info: coin, cat: cat})
}
}
sort.SliceStable(ranked, func(i, j int) bool {
switch direction {
case "losers":
return ranked[i].info.Change24hPct < ranked[j].info.Change24hPct
case "volume":
return ranked[i].info.Volume24h > ranked[j].info.Volume24h
default:
return ranked[i].info.Change24hPct > ranked[j].info.Change24hPct
}
})
if len(ranked) > limit {
ranked = ranked[:limit]
}
candidates := make([]CandidateCoin, 0, len(ranked))
source := fmt.Sprintf("hyper_rank_%s_%s", category, direction)
for _, item := range ranked {
candidates = append(candidates, CandidateCoin{Symbol: item.symbol, Sources: []string{source}})
}
logger.Infof("✅ Loaded %d Hyperliquid rank coins (%s/%s, capped at %d)", len(candidates), category, direction, limit)
return candidates, nil
}
func (e *StrategyEngine) getVergexSignalCoins(limit int, marketType, chain, liqBand, category string, selectedSymbols []string) ([]CandidateCoin, error) {
if e.vergexClient == nil {
return nil, fmt.Errorf("vergex signal source requires a configured claw402 wallet")
}
if marketType == "" {
marketType = vergex.DefaultMarketType
}
chain = vergex.QueryChain(chain)
if limit <= 0 {
limit = 5
}
if limit > store.MaxCandidateCoins {
limit = store.MaxCandidateCoins
}
category = strings.ToLower(strings.TrimSpace(category))
ranking, err := e.vergexClient.GetSignalRanking(context.Background(), vergex.Query{
Chain: chain,
LiqBand: liqBand,
})
if err != nil {
return nil, fmt.Errorf("failed to fetch Vergex signal ranking: %w", err)
}
rankedItems := vergex.FilterSignalRankingItems(ranking.Items, marketType, store.MaxCandidateCoins)
if len(rankedItems) == 0 && strings.TrimSpace(chain) != "" {
fallbackRanking, fallbackErr := e.vergexClient.GetSignalRanking(context.Background(), vergex.Query{
LiqBand: liqBand,
})
if fallbackErr == nil {
fallbackItems := vergex.FilterSignalRankingItems(fallbackRanking.Items, marketType, store.MaxCandidateCoins)
if len(fallbackItems) > 0 {
logger.Infof("✅ Vergex signal ranking returned TradeFi items after retrying without chain filter (chain=%s)", chain)
ranking = fallbackRanking
rankedItems = fallbackItems
}
} else {
logger.Warnf("⚠️ Vergex signal ranking retry without chain failed: %v", fallbackErr)
}
}
e.vergexRankingCache = make(map[string]*vergex.SignalRankItem, len(rankedItems))
for _, item := range rankedItems {
itemCopy := item
if symbol := vergex.TradableSymbolForMarket(item.MarketType, item.Symbol); symbol != "" {
e.vergexRankingCache[symbol] = &itemCopy
}
}
if len(selectedSymbols) > 0 {
candidates := make([]CandidateCoin, 0, minInt(len(selectedSymbols), limit))
seen := make(map[string]bool)
for _, raw := range selectedSymbols {
symbol := vergex.TradableSymbolForMarket(marketType, raw)
if symbol == "" || seen[symbol] {
continue
}
candidates = append(candidates, CandidateCoin{
Symbol: symbol,
Sources: []string{"vergex_signal"},
})
seen[symbol] = true
if len(candidates) >= limit {
break
}
}
if len(candidates) == 0 {
return nil, fmt.Errorf("selected Claw402 symbols are not tradable %s items", marketType)
}
logger.Infof("✅ Loaded %d selected Vergex candidates (%s)", len(candidates), marketType)
return candidates, nil
}
// Direction-balanced selection: interleave the top bullish and top bearish
// signals so the candidate universe carries BOTH long and short ideas every
// cycle (instead of filling up with whichever bias ranks highest). This is
// what lets the AI actually judge — and trade — both directions.
var bullItems, bearItems, otherItems []vergex.SignalRankItem
for _, item := range rankedItems {
if category != "" && category != "all" && item.Category != category {
continue
}
switch strings.ToLower(strings.TrimSpace(item.Bias)) {
case "bearish", "short", "sell":
bearItems = append(bearItems, item)
case "bullish", "long", "buy":
bullItems = append(bullItems, item)
default:
otherItems = append(otherItems, item)
}
}
items := make([]vergex.SignalRankItem, 0, limit)
bi, ri, oi := 0, 0, 0
for len(items) < limit {
progressed := false
if bi < len(bullItems) {
items = append(items, bullItems[bi])
bi++
progressed = true
if len(items) >= limit {
break
}
}
if ri < len(bearItems) {
items = append(items, bearItems[ri])
ri++
progressed = true
if len(items) >= limit {
break
}
}
if !progressed {
if oi < len(otherItems) {
items = append(items, otherItems[oi])
oi++
} else {
break
}
}
}
if len(items) == 0 {
if category != "" && category != "all" {
return nil, fmt.Errorf("vergex signal ranking returned no tradable %s items in category %s", marketType, category)
}
return nil, fmt.Errorf("vergex signal ranking returned no tradable %s items", marketType)
}
candidates := make([]CandidateCoin, 0, len(items))
for _, item := range items {
itemCopy := item
symbol := vergex.TradableSymbolForMarket(item.MarketType, item.Symbol)
if symbol == "" {
continue
}
e.vergexRankingCache[symbol] = &itemCopy
candidates = append(candidates, CandidateCoin{
Symbol: symbol,
Sources: []string{"vergex_signal"},
})
}
logger.Infof("✅ Loaded %d Vergex signal candidates (%s/%s, capped at %d)", len(candidates), marketType, withDefaultText(category, "all"), limit)
return candidates, nil
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
// DirectionalCandidate is a Vergex board candidate with its directional
// signal strength (the board z-score; sign follows the bias direction).
type DirectionalCandidate struct {
Symbol string
Score float64
}
// DirectionalCandidates returns bullish (long) and bearish (short) candidates
// from the most recent Vergex signal ranking, each ordered by upstream rank
// (strongest first) and carrying the signal score so callers can require a
// minimum strength. Only populated for vergex_signal coin sources, since that
// is the only source carrying a per-symbol directional bias.
func (e *StrategyEngine) DirectionalCandidates() (bullish []DirectionalCandidate, bearish []DirectionalCandidate) {
if e == nil || len(e.vergexRankingCache) == 0 {
return nil, nil
}
type ranked struct {
cand DirectionalCandidate
rank int
}
rankKey := func(r int) int {
if r > 0 {
return r
}
return 1 << 30
}
var bl, br []ranked
for sym, item := range e.vergexRankingCache {
if item == nil {
continue
}
entry := ranked{DirectionalCandidate{Symbol: sym, Score: item.Score}, item.Rank}
switch strings.ToLower(strings.TrimSpace(item.Bias)) {
case "bearish", "short", "sell":
br = append(br, entry)
case "bullish", "long", "buy":
bl = append(bl, entry)
}
}
sort.SliceStable(bl, func(i, j int) bool { return rankKey(bl[i].rank) < rankKey(bl[j].rank) })
sort.SliceStable(br, func(i, j int) bool { return rankKey(br[i].rank) < rankKey(br[j].rank) })
for _, r := range bl {
bullish = append(bullish, r.cand)
}
for _, r := range br {
bearish = append(bearish, r.cand)
}
return bullish, bearish
}
func withDefaultText(value, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return value
}
// ============================================================================
// External & Quant Data
// ============================================================================
@@ -677,6 +1025,10 @@ func (e *StrategyEngine) FetchQuantData(symbol string) (*QuantData, error) {
if !e.config.Indicators.EnableQuantData {
return nil, nil
}
if e.usesHyperliquidNativeUniverse() || market.IsXyzDexAsset(symbol) {
logger.Infof("⏭️ Skipping NofxOS quant data for Hyperliquid symbol %s; using native Hyperliquid klines/mark data only", symbol)
return nil, nil
}
// Use nofxos client with unified API key
include := "oi,price"
@@ -767,12 +1119,292 @@ func (e *StrategyEngine) FetchQuantDataBatch(symbols []string) map[string]*Quant
return result
}
func (e *StrategyEngine) FetchVergexDataBatch(ctx context.Context, symbols []string) map[string]*vergex.MarketAnalysis {
result := make(map[string]*vergex.MarketAnalysis)
if e == nil || e.config == nil || e.config.CoinSource.SourceType != "vergex_signal" {
return result
}
if e.vergexClient == nil {
logger.Warnf("⚠️ Vergex signal data skipped: claw402 wallet is not configured")
return result
}
if ctx == nil {
ctx = context.Background()
}
source := e.config.CoinSource
marketType := source.VergexMarketType
if marketType == "" {
marketType = vergex.DefaultMarketType
}
chain := source.VergexChain
chain = vergex.QueryChain(chain)
seen := make(map[string]bool)
limited := make([]string, 0, store.MaxCandidateCoins)
for _, symbol := range symbols {
symbol = vergexDetailSymbolForLookup(marketType, symbol)
if symbol == "" {
continue
}
if seen[symbol] {
continue
}
seen[symbol] = true
limited = append(limited, symbol)
if len(limited) >= store.MaxCandidateCoins+store.MaxPositions {
break
}
}
type vergexAnalysisResult struct {
symbol string
analysis *vergex.MarketAnalysis
}
resultCh := make(chan vergexAnalysisResult, len(limited))
var wg sync.WaitGroup
sem := make(chan struct{}, vergexDetailSymbolConcurrency)
for _, symbol := range limited {
symbol := symbol
querySymbol := vergex.QuerySymbol(symbol)
if querySymbol == "" {
continue
}
itemMarketType := marketType
itemCategory := ""
var ranking *vergex.SignalRankItem
if cached, ok := e.vergexRankingCache[symbol]; ok && cached != nil {
ranking = cached
if cached.MarketType != "" {
itemMarketType = cached.MarketType
}
itemCategory = cached.Category
}
analysis := &vergex.MarketAnalysis{
Symbol: symbol,
QuerySymbol: querySymbol,
MarketType: itemMarketType,
Ranking: ranking,
}
query := vergex.Query{
MarketType: itemMarketType,
Symbol: symbol,
Chain: chain,
LiqBand: source.VergexLiqBand,
Category: itemCategory,
}
wg.Add(1)
go func() {
defer wg.Done()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
analysis.SignalLabError = ctx.Err().Error()
analysis.HeatmapError = ctx.Err().Error()
resultCh <- vergexAnalysisResult{symbol: symbol, analysis: analysis}
return
}
e.populateVergexDetailData(ctx, analysis, query)
if len(analysis.SignalLab) > 0 || len(analysis.Heatmap) > 0 ||
analysis.SignalLabError != "" || analysis.HeatmapError != "" || analysis.Ranking != nil {
resultCh <- vergexAnalysisResult{symbol: symbol, analysis: analysis}
}
}()
}
wg.Wait()
close(resultCh)
for item := range resultCh {
result[item.symbol] = item.analysis
}
logger.Infof("📊 Vergex detail data ready for %d symbols", len(result))
return result
}
func vergexDetailSymbolForLookup(marketType, symbol string) string {
return vergex.TradableSymbolForMarket(marketType, symbol)
}
const (
vergexDetailRequestTimeout = 45 * time.Second
vergexDetailSymbolConcurrency = 2
)
func (e *StrategyEngine) populateVergexDetailData(ctx context.Context, analysis *vergex.MarketAnalysis, query vergex.Query) {
type endpointResult struct {
name string
body json.RawMessage
err error
}
run := func(name string, fetch func(context.Context, vergex.Query) (json.RawMessage, error), out chan<- endpointResult) {
requestCtx, cancel := context.WithTimeout(ctx, vergexDetailRequestTimeout)
defer cancel()
body, err := fetch(requestCtx, query)
out <- endpointResult{name: name, body: body, err: err}
}
out := make(chan endpointResult, 2)
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
run("signal-lab", e.fetchVergexSignalLabWithFallback, out)
}()
go func() {
defer wg.Done()
run("heatmap", e.fetchVergexHeatmapWithFallback, out)
}()
wg.Wait()
close(out)
for item := range out {
switch item.name {
case "signal-lab":
if item.err != nil {
logger.Warnf("⚠️ Failed to fetch Vergex signal-lab for %s: %v", analysis.Symbol, item.err)
analysis.SignalLabError = item.err.Error()
} else {
analysis.SignalLab = item.body
}
case "heatmap":
if item.err != nil {
logger.Warnf("⚠️ Failed to fetch Vergex heatmap for %s: %v", analysis.Symbol, item.err)
analysis.HeatmapError = item.err.Error()
} else {
analysis.Heatmap = item.body
}
}
}
}
func (e *StrategyEngine) fetchVergexSignalLabWithFallback(ctx context.Context, query vergex.Query) (json.RawMessage, error) {
var lastErr error
for idx, candidate := range vergexDetailQueryCandidates(query) {
body, err := e.vergexClient.GetSignalLab(ctx, candidate)
if err == nil {
if idx > 0 {
logger.Infof("✅ Vergex signal-lab succeeded with fallback marketType=%s chain=%s", candidate.MarketType, withDefaultText(candidate.Chain, "default"))
}
return body, nil
}
lastErr = err
if !isRetryableVergexDetailError(err) {
break
}
}
return nil, lastErr
}
func (e *StrategyEngine) fetchVergexHeatmapWithFallback(ctx context.Context, query vergex.Query) (json.RawMessage, error) {
var lastErr error
for idx, candidate := range vergexDetailQueryCandidates(query) {
body, err := e.vergexClient.GetCostLiquidationHeatmap(ctx, candidate)
if err == nil {
if idx > 0 {
logger.Infof("✅ Vergex heatmap succeeded with fallback marketType=%s chain=%s", candidate.MarketType, withDefaultText(candidate.Chain, "default"))
}
return body, nil
}
lastErr = err
if !isRetryableVergexDetailError(err) {
break
}
}
return nil, lastErr
}
func vergexDetailQueryCandidates(query vergex.Query) []vergex.Query {
marketTypes := vergexDetailMarketTypeCandidates(query)
chains := uniqueValues(query.Chain, "mainnet", "")
candidates := make([]vergex.Query, 0, len(marketTypes)*len(chains))
for _, marketType := range marketTypes {
for _, chain := range chains {
candidate := query
candidate.MarketType = marketType
candidate.Chain = chain
candidates = append(candidates, candidate)
}
}
return candidates
}
func vergexDetailMarketTypeCandidates(query vergex.Query) []string {
if isVergexAllMarketType(query.MarketType) {
if market.IsXyzDexAsset(query.Symbol) {
return uniqueNonEmpty(vergex.DefaultMarketType, "hip3-perp", "hip3Perp", "core_perp")
}
return uniqueNonEmpty("core_perp", vergex.DefaultMarketType, "hip3-perp", "hip3Perp")
}
values := []string{query.MarketType, vergex.DefaultMarketType, "hip3-perp", "hip3Perp", "core_perp"}
return uniqueNonEmpty(values...)
}
func isVergexAllMarketType(marketType string) bool {
switch strings.ToLower(strings.TrimSpace(marketType)) {
case "", "all", "any", "ranking", "signal-ranking", "signal_ranking", "claw402", "vergex":
return true
default:
return false
}
}
func isRetryableVergexDetailError(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "invalid markettype") ||
strings.Contains(msg, "invalid_request") ||
strings.Contains(msg, "invalid chain") ||
strings.Contains(msg, "market not found") ||
strings.Contains(msg, "not_found")
}
func uniqueNonEmpty(values ...string) []string {
out := make([]string, 0, len(values))
seen := make(map[string]bool, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
return out
}
func uniqueValues(values ...string) []string {
out := make([]string, 0, len(values))
seen := make(map[string]bool, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
return out
}
// FetchOIRankingData fetches market-wide OI ranking data
func (e *StrategyEngine) FetchOIRankingData() *nofxos.OIRankingData {
indicators := e.config.Indicators
if !indicators.EnableOIRanking {
return nil
}
if e.usesHyperliquidNativeUniverse() {
logger.Infof("⏭️ Skipping NofxOS OI ranking for Hyperliquid strategy; native Hyperliquid universe is the source of truth")
return nil
}
duration := indicators.OIRankingDuration
if duration == "" {
@@ -804,6 +1436,10 @@ func (e *StrategyEngine) FetchNetFlowRankingData() *nofxos.NetFlowRankingData {
if !indicators.EnableNetFlowRanking {
return nil
}
if e.usesHyperliquidNativeUniverse() {
logger.Infof("⏭️ Skipping NofxOS netflow ranking for Hyperliquid strategy; native Hyperliquid universe is the source of truth")
return nil
}
duration := indicators.NetFlowRankingDuration
if duration == "" {
@@ -836,6 +1472,10 @@ func (e *StrategyEngine) FetchPriceRankingData() *nofxos.PriceRankingData {
if !indicators.EnablePriceRanking {
return nil
}
if e.usesHyperliquidNativeUniverse() {
logger.Infof("⏭️ Skipping NofxOS price ranking for Hyperliquid strategy; native Hyperliquid universe is the source of truth")
return nil
}
durations := indicators.PriceRankingDuration
if durations == "" {

View File

@@ -84,6 +84,8 @@ func GetFullDecisionWithStrategy(ctx *Context, mcpClient mcp.AIClient, engine *S
return nil, fmt.Errorf("failed to fetch market data: %w", err)
}
}
pruneCandidateCoinsWithoutMarketData(ctx)
enrichVergexDataWithStrategy(ctx, engine)
// Ensure OITopDataMap is initialized
if ctx.OITopDataMap == nil {
@@ -141,6 +143,30 @@ func GetFullDecisionWithStrategy(ctx *Context, mcpClient mcp.AIClient, engine *S
return decision, nil
}
func enrichVergexDataWithStrategy(ctx *Context, engine *StrategyEngine) {
if ctx == nil || engine == nil || ctx.VergexDataMap != nil {
return
}
if engine.GetConfig().CoinSource.SourceType != "vergex_signal" {
return
}
symbolSet := make(map[string]bool)
symbols := make([]string, 0, len(ctx.CandidateCoins)+len(ctx.Positions))
for _, coin := range ctx.CandidateCoins {
if !symbolSet[coin.Symbol] {
symbolSet[coin.Symbol] = true
symbols = append(symbols, coin.Symbol)
}
}
for _, pos := range ctx.Positions {
if !symbolSet[pos.Symbol] {
symbolSet[pos.Symbol] = true
symbols = append(symbols, pos.Symbol)
}
}
ctx.VergexDataMap = engine.FetchVergexDataBatch(nil, symbols)
}
// ============================================================================
// Market Data Fetching
// ============================================================================
@@ -223,6 +249,21 @@ func fetchMarketDataWithStrategy(ctx *Context, engine *StrategyEngine) error {
return nil
}
func pruneCandidateCoinsWithoutMarketData(ctx *Context) {
if ctx == nil || len(ctx.CandidateCoins) == 0 || len(ctx.MarketDataMap) == 0 {
return
}
kept := make([]CandidateCoin, 0, len(ctx.CandidateCoins))
for _, coin := range ctx.CandidateCoins {
if _, ok := ctx.MarketDataMap[coin.Symbol]; ok {
kept = append(kept, coin)
continue
}
logger.Infof("⚠️ Skipping candidate %s in AI prompt: no valid market/K-line data", coin.Symbol)
}
ctx.CandidateCoins = kept
}
// ============================================================================
// AI Response Parsing
// ============================================================================

View File

@@ -0,0 +1,42 @@
package kernel
import (
"testing"
"nofx/provider/vergex"
)
func TestDirectionalCandidates(t *testing.T) {
e := &StrategyEngine{
vergexRankingCache: map[string]*vergex.SignalRankItem{
"xyz:NVDA": {Symbol: "xyz:NVDA", Bias: "bullish", Rank: 2, Score: 1.07},
"xyz:AAPL": {Symbol: "xyz:AAPL", Bias: "bullish", Rank: 1, Score: 1.76},
"BTC": {Symbol: "BTC", Bias: "bearish", Rank: 3, Score: -0.9},
"ETH": {Symbol: "ETH", Bias: "bearish", Rank: 1, Score: -0.05},
"SOL": {Symbol: "SOL", Bias: "neutral", Rank: 1, Score: 0.4},
},
}
bull, bear := e.DirectionalCandidates()
if len(bull) != 2 || bull[0].Symbol != "xyz:AAPL" || bull[1].Symbol != "xyz:NVDA" {
t.Fatalf("bullish should be rank-ordered [xyz:AAPL xyz:NVDA], got %v", bull)
}
if bull[0].Score != 1.76 || bull[1].Score != 1.07 {
t.Fatalf("bullish candidates should carry their board scores, got %v", bull)
}
if len(bear) != 2 || bear[0].Symbol != "ETH" || bear[1].Symbol != "BTC" {
t.Fatalf("bearish should be rank-ordered [ETH BTC], got %v", bear)
}
if bear[0].Score != -0.05 || bear[1].Score != -0.9 {
t.Fatalf("bearish candidates should carry their board scores, got %v", bear)
}
}
func TestDirectionalCandidatesEmpty(t *testing.T) {
e := &StrategyEngine{}
bull, bear := e.DirectionalCandidates()
if len(bull) != 0 || len(bear) != 0 {
t.Fatalf("empty cache should yield no candidates, got %v %v", bull, bear)
}
}

View File

@@ -3,6 +3,7 @@ package kernel
import (
"fmt"
"nofx/logger"
"nofx/market"
)
// ============================================================================
@@ -33,10 +34,18 @@ func validateDecision(d *Decision, accountEquity float64, btcEthLeverage, altcoi
}
if d.Action == "open_long" || d.Action == "open_short" {
// Asset tiering for validation:
// - BTC/ETH crypto perps use the BTC/ETH tier (typically 5x equity).
// - Hyperliquid XYZ assets (US equities, commodities, forex) are
// also treated as the higher tier — they are not crypto altcoins
// and the user's quick-trade flow shows them at the higher cap,
// so the validator must match.
// - Everything else is altcoin (1x equity by default).
maxLeverage := altcoinLeverage
posRatio := altcoinPosRatio
maxPositionValue := accountEquity * posRatio
if d.Symbol == "BTCUSDT" || d.Symbol == "ETHUSDT" {
isMajor := d.Symbol == "BTCUSDT" || d.Symbol == "ETHUSDT" || market.IsXyzDexAsset(d.Symbol)
if isMajor {
maxLeverage = btcEthLeverage
posRatio = btcEthPosRatio
maxPositionValue = accountEquity * posRatio
@@ -69,9 +78,12 @@ func validateDecision(d *Decision, accountEquity float64, btcEthLeverage, altcoi
tolerance := maxPositionValue * 0.01
if d.PositionSizeUSD > maxPositionValue+tolerance {
if d.Symbol == "BTCUSDT" || d.Symbol == "ETHUSDT" {
switch {
case d.Symbol == "BTCUSDT" || d.Symbol == "ETHUSDT":
return fmt.Errorf("BTC/ETH single coin position value cannot exceed %.0f USDT (%.1fx account equity), actual: %.0f", maxPositionValue, posRatio, d.PositionSizeUSD)
} else {
case market.IsXyzDexAsset(d.Symbol):
return fmt.Errorf("%s position value cannot exceed %.0f USDT (%.1fx account equity), actual: %.0f", d.Symbol, maxPositionValue, posRatio, d.PositionSizeUSD)
default:
return fmt.Errorf("altcoin single coin position value cannot exceed %.0f USDT (%.1fx account equity), actual: %.0f", maxPositionValue, posRatio, d.PositionSizeUSD)
}
}

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"nofx/market"
"nofx/provider/nofxos"
"nofx/provider/vergex"
"nofx/store"
"strings"
"time"
@@ -18,34 +19,53 @@ func (e *StrategyEngine) BuildSystemPrompt(accountEquity float64, variant string
var sb strings.Builder
riskControl := e.config.RiskControl
promptSections := e.config.PromptSections
// System prompts are intentionally English-only. UI copy can be localized,
// but the model contract should stay language-stable for an international
// open-source project and for reproducible trading behavior.
lang := LangEnglish
zh := false
singleSymbol, primarySymbol := e.singleSymbolInfo()
// Configs created in the Chinese-UI era carry legacy stored prompt sections
// and custom prompts written for a different contract; ignore them wholesale
// and fall back to the canonical built-in English sections.
legacyZhConfig := strings.EqualFold(strings.TrimSpace(e.config.Language), "zh")
if legacyZhConfig {
promptSections = store.PromptSectionsConfig{}
}
if e.usesVergexSignalPrompt() {
return e.buildVergexSystemPrompt(accountEquity, variant, lang, zh, singleSymbol, primarySymbol)
}
// 0. Data Dictionary & Schema (ensure AI understands all fields)
lang := e.GetLanguage()
schemaPrompt := GetSchemaPrompt(lang)
sb.WriteString(schemaPrompt)
sb.WriteString(GetSchemaPrompt(lang))
sb.WriteString("\n\n")
sb.WriteString("---\n\n")
// 1. Role definition (editable)
if promptSections.RoleDefinition != "" {
sb.WriteString(promptSections.RoleDefinition)
// 1. Role definition (editable; falls back to a generic intro in the
// correct language so we don't mix EN headings with ZH custom text).
roleDefinition := englishOnlyPromptSection(promptSections.RoleDefinition)
if roleDefinition != "" {
sb.WriteString(roleDefinition)
sb.WriteString("\n\n")
} else if zh {
sb.WriteString("# You are a professional Hyperliquid USDC multi-asset trading AI\n\n")
sb.WriteString("Your task is to make trading decisions based on the provided market data.\n\n")
} else {
sb.WriteString("# You are a professional cryptocurrency trading AI\n\n")
sb.WriteString("Your task is to make trading decisions based on provided market data.\n\n")
sb.WriteString("# You are a professional Hyperliquid USDC multi-asset trading AI\n\n")
sb.WriteString("Your task is to make trading decisions based on the provided market data.\n\n")
}
// 2. Trading mode variant
switch strings.ToLower(strings.TrimSpace(variant)) {
case "aggressive":
sb.WriteString("## Mode: Aggressive\n- Prioritize capturing trend breakouts, can build positions in batches when confidence ≥ 70\n- Allow higher positions, but must strictly set stop-loss and explain risk-reward ratio\n\n")
case "conservative":
sb.WriteString("## Mode: Conservative\n- Only open positions when multiple signals resonate\n- Prioritize cash preservation, must pause for multiple periods after consecutive losses\n\n")
case "scalping":
sb.WriteString("## Mode: Scalping\n- Focus on short-term momentum, smaller profit targets but require quick action\n- If price doesn't move as expected within two bars, immediately reduce position or stop-loss\n\n")
}
writeModeVariant(&sb, variant, zh)
// 3. Hard constraints (risk control)
// 3. Hard constraints (risk control).
//
// `singleSymbol` is true for strategies that deliberately trade just one
// instrument (the quick-create flow, single-asset templates). For those,
// the "BTC/ETH vs Altcoin" two-tier categorization is irrelevant and
// actively misleading — we surface a single position-value limit instead.
btcEthPosValueRatio := riskControl.BTCETHMaxPositionValueRatio
if btcEthPosValueRatio <= 0 {
btcEthPosValueRatio = 5.0
@@ -55,168 +75,678 @@ func (e *StrategyEngine) BuildSystemPrompt(accountEquity float64, variant string
altcoinPosValueRatio = 1.0
}
sb.WriteString("# Hard Constraints (Risk Control)\n\n")
sb.WriteString("## CODE ENFORCED (Backend validation, cannot be bypassed):\n")
sb.WriteString(fmt.Sprintf("- Max Positions: %d coins simultaneously\n", riskControl.MaxPositions))
sb.WriteString(fmt.Sprintf("- Position Value Limit (Altcoins): max %.0f USDT (= equity %.0f × %.1fx)\n",
accountEquity*altcoinPosValueRatio, accountEquity, altcoinPosValueRatio))
sb.WriteString(fmt.Sprintf("- Position Value Limit (BTC/ETH): max %.0f USDT (= equity %.0f × %.1fx)\n",
accountEquity*btcEthPosValueRatio, accountEquity, btcEthPosValueRatio))
sb.WriteString(fmt.Sprintf("- Max Margin Usage: ≤%.0f%%\n", riskControl.MaxMarginUsage*100))
sb.WriteString(fmt.Sprintf("- Min Position Size: ≥%.0f USDT\n\n", riskControl.MinPositionSize))
sb.WriteString("## AI GUIDED (Recommended, you should follow):\n")
sb.WriteString(fmt.Sprintf("- Trading Leverage: Altcoins max %dx | BTC/ETH max %dx\n",
riskControl.AltcoinMaxLeverage, riskControl.BTCETHMaxLeverage))
sb.WriteString(fmt.Sprintf("- Risk-Reward Ratio: ≥1:%.1f (take_profit / stop_loss)\n", riskControl.MinRiskRewardRatio))
sb.WriteString(fmt.Sprintf("- Min Confidence: ≥%d to open position\n\n", riskControl.MinConfidence))
// Position sizing guidance
sb.WriteString("## Position Sizing Guidance\n")
sb.WriteString("Calculate `position_size_usd` based on your confidence and the Position Value Limits above:\n")
sb.WriteString("- High confidence (≥85): Use 80-100%% of max position value limit\n")
sb.WriteString("- Medium confidence (70-84): Use 50-80%% of max position value limit\n")
sb.WriteString("- Low confidence (60-69): Use 30-50%% of max position value limit\n")
sb.WriteString(fmt.Sprintf("- Example: With equity %.0f and BTC/ETH ratio %.1fx, max is %.0f USDT\n",
accountEquity, btcEthPosValueRatio, accountEquity*btcEthPosValueRatio))
sb.WriteString("- **DO NOT** just use available_balance as position_size_usd. Use the Position Value Limits!\n\n")
writeHardConstraints(&sb, accountEquity, riskControl, btcEthPosValueRatio, altcoinPosValueRatio, singleSymbol, primarySymbol, zh)
// 4. Trading frequency (editable)
if promptSections.TradingFrequency != "" {
sb.WriteString(promptSections.TradingFrequency)
tradingFrequency := englishOnlyPromptSection(promptSections.TradingFrequency)
if tradingFrequency != "" {
sb.WriteString(tradingFrequency)
sb.WriteString("\n\n")
} else if zh {
sb.WriteString("# ⏱️ Trading Frequency Awareness\n\n")
sb.WriteString("- Excellent traders: 2-4 trades/day ≈ 0.1-0.2 trades/hour\n")
sb.WriteString("- >2 trades/hour = overtrading\n")
sb.WriteString("- Single position hold time ≥ 45-90 minutes\n")
sb.WriteString("If you find yourself trading every cycle → standards too low; if closing positions < 45 minutes → too impulsive.\n\n")
} else {
sb.WriteString("# ⏱️ Trading Frequency Awareness\n\n")
sb.WriteString("- Excellent traders: 2-4 trades/day ≈ 0.1-0.2 trades/hour\n")
sb.WriteString("- >2 trades/hour = Overtrading\n")
sb.WriteString("- Single position hold time ≥ 30-60 minutes\n")
sb.WriteString("If you find yourself trading every period → standards too low; if closing positions < 30 minutes → too impatient.\n\n")
sb.WriteString("- >2 trades/hour = overtrading\n")
sb.WriteString("- Single position hold time ≥ 45-90 minutes\n")
sb.WriteString("If you find yourself trading every cycle → standards too low; if closing positions < 45 minutes → too impulsive.\n\n")
}
// 5. Entry standards (editable)
if promptSections.EntryStandards != "" {
sb.WriteString(promptSections.EntryStandards)
entryStandards := englishOnlyPromptSection(promptSections.EntryStandards)
if entryStandards != "" {
sb.WriteString(entryStandards)
if zh {
sb.WriteString("\n\nYou have the following indicator data:\n")
e.writeAvailableIndicators(&sb)
} else {
sb.WriteString("\n\nYou have the following indicator data:\n")
}
e.writeAvailableIndicators(&sb, zh)
if zh {
sb.WriteString(fmt.Sprintf("\n**Confidence ≥ %d** required to open positions.\n\n", riskControl.MinConfidence))
} else {
sb.WriteString(fmt.Sprintf("\n**Confidence ≥ %d** required to open positions.\n\n", riskControl.MinConfidence))
}
} else if zh {
sb.WriteString("# 🎯 Entry Standards (Strict)\n\n")
sb.WriteString("Only open positions when multiple signals resonate. You have:\n")
e.writeAvailableIndicators(&sb, zh)
sb.WriteString(fmt.Sprintf("\nFeel free to use any effective analysis method, but **confidence ≥ %d** is required to open positions; avoid low-quality behaviors such as single-indicator entries, contradictory signals, sideways chop, or re-entering immediately after a close.\n\n", riskControl.MinConfidence))
} else {
sb.WriteString("# 🎯 Entry Standards (Strict)\n\n")
sb.WriteString("Only open positions when multiple signals resonate. You have:\n")
e.writeAvailableIndicators(&sb)
sb.WriteString(fmt.Sprintf("\nFeel free to use any effective analysis method, but **confidence ≥ %d** required to open positions; avoid low-quality behaviors such as single indicators, contradictory signals, sideways consolidation, reopening immediately after closing, etc.\n\n", riskControl.MinConfidence))
e.writeAvailableIndicators(&sb, zh)
sb.WriteString(fmt.Sprintf("\nFeel free to use any effective analysis method, but **confidence ≥ %d** is required to open positions; avoid low-quality behaviors such as single-indicator entries, contradictory signals, sideways chop, or re-entering immediately after a close.\n\n", riskControl.MinConfidence))
}
// 6. Decision process (editable)
if promptSections.DecisionProcess != "" {
sb.WriteString(promptSections.DecisionProcess)
decisionProcess := englishOnlyPromptSection(promptSections.DecisionProcess)
if decisionProcess != "" {
sb.WriteString(decisionProcess)
sb.WriteString("\n\n")
} else if zh {
sb.WriteString("# 📋 Decision Process\n\n")
sb.WriteString("1. Check positions → take profit / stop loss?\n")
sb.WriteString("2. Scan candidates + multi-timeframe → are there strong signals?\n")
sb.WriteString("3. Write chain of thought first, then output structured JSON\n\n")
} else {
sb.WriteString("# 📋 Decision Process\n\n")
sb.WriteString("1. Check positions → Should we take profit/stop-loss\n")
sb.WriteString("2. Scan candidate coins + multi-timeframe → Are there strong signals\n")
sb.WriteString("1. Check positions → take profit / stop loss?\n")
sb.WriteString("2. Scan candidates + multi-timeframe → are there strong signals?\n")
sb.WriteString("3. Write chain of thought first, then output structured JSON\n\n")
}
// 7. Output format
sb.WriteString("# Output Format (Strictly Follow)\n\n")
sb.WriteString("**Must use XML tags <reasoning> and <decision> to separate chain of thought and decision JSON, avoiding parsing errors**\n\n")
sb.WriteString("## Format Requirements\n\n")
sb.WriteString("<reasoning>\n")
sb.WriteString("Your chain of thought analysis...\n")
sb.WriteString("- Briefly analyze your thinking process \n")
sb.WriteString("</reasoning>\n\n")
sb.WriteString("<decision>\n")
sb.WriteString("Step 2: JSON decision array\n\n")
sb.WriteString("```json\n[\n")
// Use the actual configured position value ratio for BTC/ETH in the example
examplePositionSize := accountEquity * btcEthPosValueRatio
sb.WriteString(fmt.Sprintf(" {\"symbol\": \"BTCUSDT\", \"action\": \"open_short\", \"leverage\": %d, \"position_size_usd\": %.0f, \"stop_loss\": 97000, \"take_profit\": 91000, \"confidence\": 85, \"risk_usd\": 300},\n",
riskControl.BTCETHMaxLeverage, examplePositionSize))
sb.WriteString(" {\"symbol\": \"ETHUSDT\", \"action\": \"close_long\"}\n")
sb.WriteString("]\n```\n")
sb.WriteString("</decision>\n\n")
sb.WriteString("## Field Description\n\n")
sb.WriteString("- `action`: open_long | open_short | close_long | close_short | hold | wait\n")
sb.WriteString(fmt.Sprintf("- `confidence`: 0-100 (opening recommended ≥ %d)\n", riskControl.MinConfidence))
sb.WriteString("- Required when opening: leverage, position_size_usd, stop_loss, take_profit, confidence, risk_usd\n")
sb.WriteString("- **IMPORTANT**: All numeric values must be calculated numbers, NOT formulas/expressions (e.g., use `27.76` not `3000 * 0.01`)\n\n")
// 7. Output format — schema spec stays in English (this is a parser
// contract; reasoning copy is localized below).
writeOutputFormat(&sb, accountEquity, btcEthPosValueRatio, riskControl, singleSymbol, primarySymbol, zh)
// 8. Custom Prompt
if e.config.CustomPrompt != "" {
// 8. Custom Prompt.
//
// For single-symbol Hyperliquid XYZ assets (US equities, commodities,
// forex), we replace any stored CustomPrompt with a built-in English
// stock-trader template. This serves two purposes:
// 1. The auto-generated CustomPrompt from the quick-create flow used
// to be Chinese (matching UI language), which produced an
// incoherent mixed-language final prompt that confused the LLM.
// 2. It guarantees a stock-specific, US-equity-tuned briefing
// regardless of when the strategy was first created.
customPrompt := englishOnlyPromptSection(e.config.CustomPrompt)
if legacyZhConfig {
customPrompt = ""
}
if singleSymbol && market.IsXyzDexAsset(primarySymbol) {
customPrompt = buildXYZStockCustomPrompt(primarySymbol)
}
if customPrompt != "" {
if zh {
sb.WriteString("# 📌 Personalized Trading Strategy\n\n")
sb.WriteString(e.config.CustomPrompt)
} else {
sb.WriteString("# 📌 Personalized Trading Strategy\n\n")
}
sb.WriteString(customPrompt)
sb.WriteString("\n\n")
sb.WriteString("Note: The above personalized strategy is a supplement to the basic rules and cannot violate the basic risk control principles.\n")
if zh {
sb.WriteString("Note: the above personalized strategy supplements the basic rules and may not violate the core risk controls.\n")
} else {
sb.WriteString("Note: the above personalized strategy supplements the basic rules and may not violate the core risk controls.\n")
}
}
return sb.String()
}
func (e *StrategyEngine) writeAvailableIndicators(sb *strings.Builder) {
func (e *StrategyEngine) usesVergexSignalPrompt() bool {
if e == nil || e.config == nil {
return false
}
coinSource := e.config.CoinSource
sourceType := strings.ToLower(strings.TrimSpace(coinSource.SourceType))
return sourceType == "vergex_signal" ||
sourceType == "claw402" ||
sourceType == "claw402_vergex" ||
coinSource.VergexMarketType != "" ||
coinSource.VergexChain != "" ||
coinSource.VergexLimit > 0
}
func (e *StrategyEngine) buildVergexSystemPrompt(accountEquity float64, variant string, lang Language, zh bool, singleSymbol bool, primarySymbol string) string {
var sb strings.Builder
riskControl := e.config.RiskControl
writeVergexSchemaPrompt(&sb, zh)
sb.WriteString("\n\n---\n\n")
if zh {
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("# Decision Data Priority\n\n")
sb.WriteString("1. Claw402.ai Signal Ranking: candidate pool, rank, direction and category.\n")
sb.WriteString("2. Claw402.ai Signal Lab: trend, momentum, event/model confirmation; this is the core pre-entry confirmation source.\n")
sb.WriteString("3. Claw402.ai Cost/Liquidation Heatmap: crowded liquidation/cost zones, stop placement and target zones.\n")
sb.WriteString("4. Raw OHLCV candles: entry timing, trend structure, volatility and risk/reward validation.\n\n")
sb.WriteString("# Trading Rules\n\n")
sb.WriteString("- Manage existing positions before opening new ones.\n")
sb.WriteString("- Open only when Signal Lab, heatmap and raw candles broadly agree; wait when key data is missing or contradictory.\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("- 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("- Hold for BIG moves, do not churn: hold new positions for at least 4 hours; never close inside the -4%% to +6%% noise band before ~8 hours; after closing a symbol wait 3 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")
sb.WriteString("- Fees are the main edge killer: a round trip costs ~0.1%% of notional. Only enter setups where the realistic target is a LARGE move: stop-loss around -5%% and take-profit around +10-12%% (roughly 2:1 or better). Do not aim for 0.5%% scalps — they cannot cover fees. Fewer, high-conviction, wide-target, multi-hour holds only.\n")
sb.WriteString("- Set WIDE stops and targets: place the stop well beyond short-term noise (around -5%%) and the target at a distant heatmap resistance/liquidation zone (around +10-12%%). Give the position room to develop; do not exit on small green or small red.\n\n")
} else {
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("# Decision Data Priority\n\n")
sb.WriteString("1. Claw402.ai Signal Ranking: candidate pool, rank, direction and category.\n")
sb.WriteString("2. Claw402.ai Signal Lab: trend, momentum, event/model confirmation; this is the core pre-entry confirmation source.\n")
sb.WriteString("3. Claw402.ai Cost/Liquidation Heatmap: crowded liquidation/cost zones, stop placement and target zones.\n")
sb.WriteString("4. Raw OHLCV candles: entry timing, trend structure, volatility and risk/reward validation.\n\n")
sb.WriteString("# Trading Rules\n\n")
sb.WriteString("- Manage existing positions before opening new ones.\n")
sb.WriteString("- Open only when Signal Lab, heatmap and raw candles broadly agree; wait when key data is missing or contradictory.\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("- 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("- Hold for BIG moves, do not churn: hold new positions for at least 4 hours; never close inside the -4%% to +6%% noise band before ~8 hours; after closing a symbol wait 3 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")
sb.WriteString("- Fees are the main edge killer: a round trip costs ~0.1%% of notional. Only enter setups where the realistic target is a LARGE move: stop-loss around -5%% and take-profit around +10-12%% (roughly 2:1 or better). Do not aim for 0.5%% scalps — they cannot cover fees. Fewer, high-conviction, wide-target, multi-hour holds only.\n")
sb.WriteString("- Set WIDE stops and targets: place the stop well beyond short-term noise (around -5%%) and the target at a distant heatmap resistance/liquidation zone (around +10-12%%). Give the position room to develop; do not exit on small green or small red.\n\n")
}
writeModeVariant(&sb, variant, zh)
altcoinPosValueRatio := riskControl.AltcoinMaxPositionValueRatio
if altcoinPosValueRatio <= 0 {
altcoinPosValueRatio = 1.0
}
writeVergexHardConstraints(&sb, accountEquity, riskControl, altcoinPosValueRatio, zh)
writeVergexOutputFormat(&sb, accountEquity, riskControl, altcoinPosValueRatio, singleSymbol, primarySymbol, zh)
customPrompt := vergexCustomPromptSection(e.config.CustomPrompt)
if customPrompt != "" {
sb.WriteString("# User Preference\n\n")
sb.WriteString(customPrompt)
sb.WriteString("\n\n")
}
return sb.String()
}
// vergexCustomPromptSection returns the user's custom prompt for the vergex
// path, dropping legacy directional overrides ("long only" era) that would
// contradict the data-driven direction rule baked into this prompt.
func vergexCustomPromptSection(section string) string {
trimmed := englishOnlyPromptSection(section)
if trimmed == "" {
return ""
}
lower := strings.ToLower(trimmed)
legacyDirectives := []string{
"long only",
"long-only",
"do not short",
"no shorts",
"must open a long",
"short only",
"short-only",
}
for _, directive := range legacyDirectives {
if strings.Contains(lower, directive) {
return ""
}
}
return trimmed
}
func englishOnlyPromptSection(section string) string {
trimmed := strings.TrimSpace(section)
if trimmed == "" {
return ""
}
if detectLanguage(trimmed) == LangChinese {
return ""
}
return trimmed
}
func writeVergexSchemaPrompt(sb *strings.Builder, zh bool) {
if zh {
sb.WriteString("# Claw402.ai TradeFi Data Guide\n\n")
sb.WriteString("- Equity: total account value including unrealized PnL, in USDT.\n")
sb.WriteString("- Balance: available balance for new positions, in USDT.\n")
sb.WriteString("- Margin: current margin usage; higher means more risk.\n")
sb.WriteString("- Position: current holdings with side, entry, leverage, unrealized PnL and liquidation price.\n")
sb.WriteString("- Claw402 Ranking: tradable candidate pool, rank, direction and category for this cycle.\n")
sb.WriteString("- Signal Lab: per-symbol Claw402 deep signal used to confirm trend and quality.\n")
sb.WriteString("- Cost/Liquidation Heatmap: cost and liquidation clusters used for stops, targets and crowding risk.\n")
sb.WriteString("- Raw OHLCV Kline: raw candles used for trend structure, entry timing and risk/reward.\n")
} else {
sb.WriteString("# Claw402.ai TradeFi Data Guide\n\n")
sb.WriteString("- Equity: total account value including unrealized PnL, in USDT.\n")
sb.WriteString("- Balance: available balance for new positions, in USDT.\n")
sb.WriteString("- Margin: current margin usage; higher means more risk.\n")
sb.WriteString("- Position: current holdings with side, entry, leverage, unrealized PnL and liquidation price.\n")
sb.WriteString("- Claw402 Ranking: tradable candidate pool, rank, direction and category for this cycle.\n")
sb.WriteString("- Signal Lab: per-symbol Claw402 deep signal used to confirm trend and quality.\n")
sb.WriteString("- Cost/Liquidation Heatmap: cost and liquidation clusters used for stops, targets and crowding risk.\n")
sb.WriteString("- Raw OHLCV Kline: raw candles used for trend structure, entry timing and risk/reward.\n")
}
}
func writeVergexHardConstraints(sb *strings.Builder, accountEquity float64, riskControl store.RiskControlConfig, tradeFiPositionValueRatio float64, zh bool) {
maxPositionValue := accountEquity * tradeFiPositionValueRatio
if zh {
sb.WriteString("# Hard Risk Constraints\n\n")
sb.WriteString("## Backend enforced\n")
sb.WriteString(fmt.Sprintf("- Max positions: %d Claw402 candidate instruments at the same time\n", riskControl.MaxPositions))
sb.WriteString(fmt.Sprintf("- Max notional per position: %.0f USDT (= equity %.0f × %.1fx)\n", maxPositionValue, accountEquity, tradeFiPositionValueRatio))
sb.WriteString(fmt.Sprintf("- Max margin usage: ≤%.0f%%\n", riskControl.MaxMarginUsage*100))
sb.WriteString(fmt.Sprintf("- Min order size: ≥%.0f USDT\n\n", riskControl.MinPositionSize))
sb.WriteString("## AI guided\n")
sb.WriteString(fmt.Sprintf("- Leverage: every open position must use exactly %dx\n", riskControl.AltcoinMaxLeverage))
sb.WriteString(fmt.Sprintf("- Risk/reward: ≥1:%.1f\n", riskControl.MinRiskRewardRatio))
sb.WriteString(fmt.Sprintf("- Min confidence to open: ≥%d\n\n", riskControl.MinConfidence))
sb.WriteString("# Position Sizing\n\n")
sb.WriteString("For every `open_long` or `open_short`, use the full max notional per position.\n")
sb.WriteString("- Do not scale position_size_usd down by confidence.\n")
sb.WriteString("- Do not open small probe positions.\n")
sb.WriteString("- If the setup is not strong enough for full size, output `wait`.\n")
sb.WriteString("- Do not use available_balance directly as position_size_usd.\n\n")
} else {
sb.WriteString("# Hard Risk Constraints\n\n")
sb.WriteString("## Backend enforced\n")
sb.WriteString(fmt.Sprintf("- Max positions: %d Claw402 candidate instruments at the same time\n", riskControl.MaxPositions))
sb.WriteString(fmt.Sprintf("- Max notional per position: %.0f USDT (= equity %.0f × %.1fx)\n", maxPositionValue, accountEquity, tradeFiPositionValueRatio))
sb.WriteString(fmt.Sprintf("- Max margin usage: ≤%.0f%%\n", riskControl.MaxMarginUsage*100))
sb.WriteString(fmt.Sprintf("- Min order size: ≥%.0f USDT\n\n", riskControl.MinPositionSize))
sb.WriteString("## AI guided\n")
sb.WriteString(fmt.Sprintf("- Leverage: every open position must use exactly %dx\n", riskControl.AltcoinMaxLeverage))
sb.WriteString(fmt.Sprintf("- Risk/reward: ≥1:%.1f\n", riskControl.MinRiskRewardRatio))
sb.WriteString(fmt.Sprintf("- Min confidence to open: ≥%d\n\n", riskControl.MinConfidence))
sb.WriteString("# Position Sizing\n\n")
sb.WriteString("For every `open_long` or `open_short`, use the full max notional per position.\n")
sb.WriteString("- Do not scale position_size_usd down by confidence.\n")
sb.WriteString("- Do not open small probe positions.\n")
sb.WriteString("- If the setup is not strong enough for full size, output `wait`.\n")
sb.WriteString("- Do not use available_balance directly as position_size_usd.\n\n")
}
}
func writeVergexOutputFormat(sb *strings.Builder, accountEquity float64, riskControl store.RiskControlConfig, tradeFiPositionValueRatio float64, singleSymbol bool, primarySymbol string, zh bool) {
exampleSymbol := "xyz:NVDA"
secondSymbol := "xyz:AAPL"
if singleSymbol && strings.TrimSpace(primarySymbol) != "" {
exampleSymbol = primarySymbol
secondSymbol = primarySymbol
}
positionSize := accountEquity * tradeFiPositionValueRatio
leverage := riskControl.AltcoinMaxLeverage
if leverage <= 0 {
leverage = 1
}
sb.WriteString("# Output Format (Strictly Follow)\n\n")
if zh {
sb.WriteString("Use XML tags <reasoning> and <decision> to separate concise analysis from the decision JSON.\n\n")
sb.WriteString("Direction must be data-driven: use `open_long` for confirmed upside structures and `open_short` for confirmed downside structures; never default to long-only or short-only behavior.\n\n")
if !singleSymbol {
sb.WriteString("Evaluate both directions every cycle, but enter a side only when its own signals independently justify it. Never open a position just to balance the book — an unbalanced book beats a forced trade.\n\n")
}
} else {
sb.WriteString("Use XML tags <reasoning> and <decision> to separate concise analysis from the decision JSON.\n\n")
sb.WriteString("Direction must be data-driven: use `open_long` for confirmed upside structures and `open_short` for confirmed downside structures; never default to long-only or short-only behavior.\n\n")
if !singleSymbol {
sb.WriteString("Evaluate both directions every cycle, but enter a side only when its own signals independently justify it. Never open a position just to balance the book — an unbalanced book beats a forced trade.\n\n")
}
}
sb.WriteString("<reasoning>\n")
if zh {
sb.WriteString("Briefly state whether Claw402 ranking, Signal Lab, heatmap and candles agree; if data is missing or conflicting, explain why you wait.\n")
} else {
sb.WriteString("Briefly state whether Claw402 ranking, Signal Lab, heatmap and candles agree; if data is missing or conflicting, explain why you wait.\n")
}
sb.WriteString("</reasoning>\n\n")
sb.WriteString("<decision>\n")
sb.WriteString("```json\n[\n")
if singleSymbol {
sb.WriteString(fmt.Sprintf(" {\"symbol\": \"%s\", \"action\": \"open_short\", \"leverage\": %d, \"position_size_usd\": %.0f, \"stop_loss\": 0, \"take_profit\": 0, \"confidence\": 85, \"risk_usd\": 0}\n", exampleSymbol, leverage, positionSize))
} else {
sb.WriteString(fmt.Sprintf(" {\"symbol\": \"%s\", \"action\": \"open_long\", \"leverage\": %d, \"position_size_usd\": %.0f, \"stop_loss\": 0, \"take_profit\": 0, \"confidence\": 85, \"risk_usd\": 0},\n", exampleSymbol, leverage, positionSize))
sb.WriteString(fmt.Sprintf(" {\"symbol\": \"%s\", \"action\": \"open_short\", \"leverage\": %d, \"position_size_usd\": %.0f, \"stop_loss\": 0, \"take_profit\": 0, \"confidence\": 85, \"risk_usd\": 0}\n", secondSymbol, leverage, positionSize))
}
sb.WriteString("]\n```\n")
sb.WriteString("</decision>\n\n")
if zh {
sb.WriteString("## Field Requirements\n\n")
sb.WriteString("- `action`: open_long | open_short | close_long | close_short | hold | wait\n")
sb.WriteString(fmt.Sprintf("- `confidence`: 0-100; recommended ≥ %d to open\n", riskControl.MinConfidence))
sb.WriteString("- Required when opening: leverage, position_size_usd, stop_loss, take_profit, confidence, risk_usd\n")
sb.WriteString("- All numeric values must be calculated numbers, not formulas.\n")
if singleSymbol {
sb.WriteString(fmt.Sprintf("- This strategy trades only `%s`; JSON symbol must match it exactly.\n", exampleSymbol))
} else {
sb.WriteString("- JSON symbols must exactly match current candidates or existing positions; keep `xyz:` on XYZ instruments, and do not add `xyz:` or `USDT` to core crypto symbols.\n")
}
sb.WriteString("\n")
} else {
sb.WriteString("## Field Requirements\n\n")
sb.WriteString("- `action`: open_long | open_short | close_long | close_short | hold | wait\n")
sb.WriteString(fmt.Sprintf("- `confidence`: 0-100; recommended ≥ %d to open\n", riskControl.MinConfidence))
sb.WriteString("- Required when opening: leverage, position_size_usd, stop_loss, take_profit, confidence, risk_usd\n")
sb.WriteString("- All numeric values must be calculated numbers, not formulas.\n")
if singleSymbol {
sb.WriteString(fmt.Sprintf("- This strategy trades only `%s`; JSON symbol must match it exactly.\n", exampleSymbol))
} else {
sb.WriteString("- JSON symbols must exactly match current candidates or existing positions; keep `xyz:` on XYZ instruments, and do not add `xyz:` or `USDT` to core crypto symbols.\n")
}
sb.WriteString("\n")
}
}
// buildXYZStockCustomPrompt returns the canonical English directional stock
// briefing the agent uses for single-symbol Hyperliquid USDC perpetuals on
// the XYZ board. Symbol is inlined for LLM grounding so it never confuses the
// trading instrument.
func buildXYZStockCustomPrompt(symbol string) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Trade ONLY the Hyperliquid USDC perpetual %s (US equity / xyz board).\n\n", symbol))
sb.WriteString("Core stance: DIRECTIONAL, SIGNAL-DRIVEN. You may open long or short; never force a trade when Signal Lab, liquidation structure and candles disagree.\n\n")
sb.WriteString("## Flat-Account Rule\n")
sb.WriteString("If `Current Positions` is None / empty, evaluate both directions from scratch.\n")
sb.WriteString("- Use `open_long` only when upside continuation or bullish reversal is confirmed.\n")
sb.WriteString("- Use `open_short` only when downside continuation or bearish reversal is confirmed.\n")
sb.WriteString("- Use `wait` when neither side meets the minimum confidence and risk/reward threshold.\n")
sb.WriteString("- Do not raise confidence just to force an order; confidence must reflect the evidence.\n\n")
sb.WriteString("## Long Entry Conditions\n")
sb.WriteString("- Break of the prior session/intraday high on rising volume.\n")
sb.WriteString("- Pullback to a clearly held intraday support (prior swing low, VWAP, EMA20/50) with a bullish reaction bar.\n")
sb.WriteString("- Sector tape strength (broad US-equity bid, sympathy with peers in the same theme).\n")
sb.WriteString("- Confirmed catalyst: earnings beat, guide up, sector rotation, macro tailwind.\n\n")
sb.WriteString("## Short Entry Conditions\n")
sb.WriteString("- Breakdown below intraday support or value area with expanding volume.\n")
sb.WriteString("- Failed breakout, lower high, or bearish rejection at resistance.\n")
sb.WriteString("- Signal Lab / liquidation structure shows downside fuel, trapped longs, or weak support below.\n")
sb.WriteString("- Negative catalyst: earnings miss, guide down, sector weakness, macro headwind.\n\n")
sb.WriteString("## Risk Guardrails (non-negotiable)\n")
sb.WriteString("- Per-trade stop-loss: 1.5-3% from entry. ALWAYS set a numeric `stop_loss`.\n")
sb.WriteString("- Take-profit: target at least R/R 2:1; set a numeric `take_profit`.\n")
sb.WriteString("- Per-trade notional: <= 25% of account equity (probing 10-15%, full 20-25%).\n")
sb.WriteString("- Leverage: 2-3x default, never above 5x. Never go all-in.\n")
sb.WriteString("- Do not flip directly from long to short or short to long in the same cycle. Manage or close the open position first.\n\n")
sb.WriteString("## Position Management\n")
sb.WriteString("- Trail stop to breakeven once +1R, take partial profits at +2R if momentum stalls.\n")
sb.WriteString("- Cut quickly if price breaks the stop or the catalyst thesis fails.\n")
sb.WriteString("- Holding past 45 minutes is fine; flipping in/out every cycle is not.\n\n")
sb.WriteString("## Discipline\n")
sb.WriteString(fmt.Sprintf("- Single-symbol mandate: never rotate into another ticker. The decision JSON `symbol` MUST be exactly \"%s\".\n", symbol))
sb.WriteString("- Before every decision: check current price vs prior pivot, volume vs 5m/1h average, and the broader US-equity tape.\n")
sb.WriteString("- If positions are open, prioritize managing them over piling on new ones.")
return sb.String()
}
// singleSymbolInfo returns (true, "ARM-USDC") for static-coin strategies that
// trade exactly one instrument. Multi-symbol strategies return (false, "").
// The flag is used to drop crypto-specific "BTC/ETH vs Altcoin" labeling and
// to put the actual trading symbol into the JSON example.
func (e *StrategyEngine) singleSymbolInfo() (bool, string) {
coinSource := e.config.CoinSource
if (coinSource.SourceType == "static" || coinSource.SourceType == "vergex_signal") && len(coinSource.StaticCoins) == 1 {
return true, strings.ToUpper(strings.TrimSpace(coinSource.StaticCoins[0]))
}
return false, ""
}
func writeModeVariant(sb *strings.Builder, variant string, zh bool) {
switch strings.ToLower(strings.TrimSpace(variant)) {
case "aggressive":
if zh {
sb.WriteString("## Mode: Aggressive\n- Prioritize capturing trend breakouts; may scale in when confidence ≥ 70\n- Allow larger positions, but must strictly set stop-loss and explain the risk-reward ratio\n\n")
} else {
sb.WriteString("## Mode: Aggressive\n- Prioritize capturing trend breakouts; may scale in when confidence ≥ 70\n- Allow larger positions, but must strictly set stop-loss and explain the risk-reward ratio\n\n")
}
case "conservative":
if zh {
sb.WriteString("## Mode: Conservative\n- Open positions only when multiple signals resonate\n- Prioritize capital preservation; pause for multiple periods after consecutive losses\n\n")
} else {
sb.WriteString("## Mode: Conservative\n- Open positions only when multiple signals resonate\n- Prioritize capital preservation; pause for multiple periods after consecutive losses\n\n")
}
case "scalping":
if zh {
sb.WriteString("## Mode: Scalping\n- Focus on short-term momentum, smaller profit targets but require quick action\n- If price doesn't move as expected within two bars, immediately reduce position or stop-loss\n\n")
} else {
sb.WriteString("## Mode: Scalping\n- Focus on short-term momentum, smaller profit targets but require quick action\n- If price doesn't move as expected within two bars, immediately reduce position or stop-loss\n\n")
}
}
}
func writeHardConstraints(sb *strings.Builder, accountEquity float64, riskControl store.RiskControlConfig, btcEthPosValueRatio, altcoinPosValueRatio float64, singleSymbol bool, primarySymbol string, zh bool) {
if zh {
sb.WriteString("# Hard Constraints (Risk Control)\n\n")
sb.WriteString("## CODE ENFORCED (backend validation, cannot be bypassed):\n")
sb.WriteString(fmt.Sprintf("- Max Positions: %d instruments simultaneously\n", riskControl.MaxPositions))
} else {
sb.WriteString("# Hard Constraints (Risk Control)\n\n")
sb.WriteString("## CODE ENFORCED (backend validation, cannot be bypassed):\n")
sb.WriteString(fmt.Sprintf("- Max Positions: %d instruments simultaneously\n", riskControl.MaxPositions))
}
if singleSymbol {
// One symbol — pick the higher of the two configured ratios so the
// limit isn't accidentally clamped to the altcoin cap for a stock.
ratio := altcoinPosValueRatio
if btcEthPosValueRatio > ratio {
ratio = btcEthPosValueRatio
}
maxVal := accountEquity * ratio
symLabel := primarySymbol
if zh {
sb.WriteString(fmt.Sprintf("- Position Value Limit (%s): max %.0f USDT (= equity %.0f × %.1fx)\n", symLabel, maxVal, accountEquity, ratio))
} else {
sb.WriteString(fmt.Sprintf("- Position Value Limit (%s): max %.0f USDT (= equity %.0f × %.1fx)\n", symLabel, maxVal, accountEquity, ratio))
}
} else {
if zh {
sb.WriteString(fmt.Sprintf("- Position Value Limit (Altcoin/Stock): max %.0f USDT (= equity %.0f × %.1fx)\n", accountEquity*altcoinPosValueRatio, accountEquity, altcoinPosValueRatio))
sb.WriteString(fmt.Sprintf("- Position Value Limit (BTC/ETH): max %.0f USDT (= equity %.0f × %.1fx)\n", accountEquity*btcEthPosValueRatio, accountEquity, btcEthPosValueRatio))
} else {
sb.WriteString(fmt.Sprintf("- Position Value Limit (Altcoin/Stock): max %.0f USDT (= equity %.0f × %.1fx)\n", accountEquity*altcoinPosValueRatio, accountEquity, altcoinPosValueRatio))
sb.WriteString(fmt.Sprintf("- Position Value Limit (BTC/ETH): max %.0f USDT (= equity %.0f × %.1fx)\n", accountEquity*btcEthPosValueRatio, accountEquity, btcEthPosValueRatio))
}
}
if zh {
sb.WriteString(fmt.Sprintf("- Max Margin Usage: ≤%.0f%%\n", riskControl.MaxMarginUsage*100))
sb.WriteString(fmt.Sprintf("- Min Position Size: ≥%.0f USDT\n\n", riskControl.MinPositionSize))
sb.WriteString("## AI GUIDED (recommended):\n")
} else {
sb.WriteString(fmt.Sprintf("- Max Margin Usage: ≤%.0f%%\n", riskControl.MaxMarginUsage*100))
sb.WriteString(fmt.Sprintf("- Min Position Size: ≥%.0f USDT\n\n", riskControl.MinPositionSize))
sb.WriteString("## AI GUIDED (recommended):\n")
}
if singleSymbol {
lev := riskControl.AltcoinMaxLeverage
if riskControl.BTCETHMaxLeverage > lev {
lev = riskControl.BTCETHMaxLeverage
}
if zh {
sb.WriteString(fmt.Sprintf("- Trading Leverage (%s): max %dx\n", primarySymbol, lev))
} else {
sb.WriteString(fmt.Sprintf("- Trading Leverage (%s): max %dx\n", primarySymbol, lev))
}
} else {
if zh {
sb.WriteString(fmt.Sprintf("- Trading Leverage: Altcoin/Stock max %dx | BTC/ETH max %dx\n", riskControl.AltcoinMaxLeverage, riskControl.BTCETHMaxLeverage))
} else {
sb.WriteString(fmt.Sprintf("- Trading Leverage: Altcoin/Stock max %dx | BTC/ETH max %dx\n", riskControl.AltcoinMaxLeverage, riskControl.BTCETHMaxLeverage))
}
}
if zh {
sb.WriteString(fmt.Sprintf("- Risk-Reward Ratio: ≥1:%.1f (take_profit / stop_loss)\n", riskControl.MinRiskRewardRatio))
sb.WriteString(fmt.Sprintf("- Min Confidence: ≥%d to open position\n\n", riskControl.MinConfidence))
} else {
sb.WriteString(fmt.Sprintf("- Risk-Reward Ratio: ≥1:%.1f (take_profit / stop_loss)\n", riskControl.MinRiskRewardRatio))
sb.WriteString(fmt.Sprintf("- Min Confidence: ≥%d to open position\n\n", riskControl.MinConfidence))
}
// Position sizing guidance
exampleRatio := btcEthPosValueRatio
if singleSymbol {
exampleRatio = altcoinPosValueRatio
if btcEthPosValueRatio > exampleRatio {
exampleRatio = btcEthPosValueRatio
}
}
if zh {
sb.WriteString("## Position Sizing Guidance\n")
sb.WriteString("Calculate `position_size_usd` from your confidence and the Position Value Limits above:\n")
sb.WriteString("- High confidence (≥85): use 80-100%% of the position value limit\n")
sb.WriteString("- Medium confidence (70-84): use 50-80%% of the position value limit\n")
sb.WriteString("- Low confidence (60-69): use 30-50%% of the position value limit\n")
sb.WriteString(fmt.Sprintf("- Example: equity %.0f × %.1fx = max %.0f USDT\n", accountEquity, exampleRatio, accountEquity*exampleRatio))
sb.WriteString("- **DO NOT** just use available_balance as position_size_usd. Use the Position Value Limit!\n\n")
} else {
sb.WriteString("## Position Sizing Guidance\n")
sb.WriteString("Calculate `position_size_usd` from your confidence and the Position Value Limits above:\n")
sb.WriteString("- High confidence (≥85): use 80-100%% of the position value limit\n")
sb.WriteString("- Medium confidence (70-84): use 50-80%% of the position value limit\n")
sb.WriteString("- Low confidence (60-69): use 30-50%% of the position value limit\n")
sb.WriteString(fmt.Sprintf("- Example: equity %.0f × %.1fx = max %.0f USDT\n", accountEquity, exampleRatio, accountEquity*exampleRatio))
sb.WriteString("- **DO NOT** just use available_balance as position_size_usd. Use the Position Value Limit!\n\n")
}
}
func writeOutputFormat(sb *strings.Builder, accountEquity, btcEthPosValueRatio float64, riskControl store.RiskControlConfig, singleSymbol bool, primarySymbol string, zh bool) {
// Output format schema MUST stay English/structural; parser depends on it.
sb.WriteString("# Output Format (Strictly Follow)\n\n")
if zh {
sb.WriteString("**Must use XML tags <reasoning> and <decision> to separate chain of thought and decision JSON, avoiding parsing errors**\n\n")
} else {
sb.WriteString("**Must use XML tags <reasoning> and <decision> to separate chain of thought and decision JSON, avoiding parsing errors**\n\n")
}
sb.WriteString("## Format Requirements\n\n")
sb.WriteString("<reasoning>\n")
if zh {
sb.WriteString("Your chain of thought analysis...\n- Briefly analyze your thinking process\n")
} else {
sb.WriteString("Your chain of thought analysis...\n- Briefly analyze your thinking process\n")
}
sb.WriteString("</reasoning>\n\n")
sb.WriteString("<decision>\n")
if zh {
sb.WriteString("Step 2: JSON decision array\n\n")
} else {
sb.WriteString("Step 2: JSON decision array\n\n")
}
sb.WriteString("```json\n[\n")
// Build a JSON example using the actual trading symbol when the strategy
// is single-symbol. Falls back to the legacy BTC/ETH two-line example
// only for multi-symbol strategies that genuinely have BTC/ETH on tap.
if singleSymbol {
lev := riskControl.AltcoinMaxLeverage
if riskControl.BTCETHMaxLeverage > lev {
lev = riskControl.BTCETHMaxLeverage
}
ratio := btcEthPosValueRatio // already chosen as the larger above when single-symbol
size := accountEquity * ratio
sb.WriteString(fmt.Sprintf(" {\"symbol\": \"%s\", \"action\": \"open_long\", \"leverage\": %d, \"position_size_usd\": %.0f, \"stop_loss\": 0, \"take_profit\": 0, \"confidence\": 85, \"risk_usd\": 0},\n", primarySymbol, lev, size))
sb.WriteString(fmt.Sprintf(" {\"symbol\": \"%s\", \"action\": \"wait\"}\n", primarySymbol))
} else {
examplePositionSize := accountEquity * btcEthPosValueRatio
sb.WriteString(fmt.Sprintf(" {\"symbol\": \"BTCUSDT\", \"action\": \"open_short\", \"leverage\": %d, \"position_size_usd\": %.0f, \"stop_loss\": 97000, \"take_profit\": 91000, \"confidence\": 85, \"risk_usd\": 300},\n",
riskControl.BTCETHMaxLeverage, examplePositionSize))
sb.WriteString(" {\"symbol\": \"ETHUSDT\", \"action\": \"close_long\"}\n")
}
sb.WriteString("]\n```\n")
sb.WriteString("</decision>\n\n")
if zh {
sb.WriteString("## Field Description\n\n")
sb.WriteString("- `action`: open_long | open_short | close_long | close_short | hold | wait\n")
sb.WriteString(fmt.Sprintf("- `confidence`: 0-100 (opening recommended ≥ %d)\n", riskControl.MinConfidence))
sb.WriteString("- Required when opening: leverage, position_size_usd, stop_loss, take_profit, confidence, risk_usd\n")
sb.WriteString("- **IMPORTANT**: all numeric values must be calculated numbers, NOT formulas/expressions (e.g. use `27.76`, not `3000 * 0.01`)\n")
if singleSymbol {
sb.WriteString(fmt.Sprintf("- **This strategy trades only %s.** The JSON `symbol` MUST match `%s` exactly — do not write `%s` variants that drop the suffix or add USDT.\n", primarySymbol, primarySymbol, primarySymbol))
}
sb.WriteString("\n")
} else {
sb.WriteString("## Field Description\n\n")
sb.WriteString("- `action`: open_long | open_short | close_long | close_short | hold | wait\n")
sb.WriteString(fmt.Sprintf("- `confidence`: 0-100 (opening recommended ≥ %d)\n", riskControl.MinConfidence))
sb.WriteString("- Required when opening: leverage, position_size_usd, stop_loss, take_profit, confidence, risk_usd\n")
sb.WriteString("- **IMPORTANT**: all numeric values must be calculated numbers, NOT formulas/expressions (e.g. use `27.76`, not `3000 * 0.01`)\n")
if singleSymbol {
sb.WriteString(fmt.Sprintf("- **This strategy trades only %s.** The JSON `symbol` MUST match `%s` exactly — do not add USDT/USDC suffix variants.\n", primarySymbol, primarySymbol))
}
sb.WriteString("\n")
}
}
func (e *StrategyEngine) writeAvailableIndicators(sb *strings.Builder, zh bool) {
indicators := e.config.Indicators
kline := indicators.Klines
label := func(en, zhStr string) string {
if zh {
return zhStr
}
return en
}
if zh {
sb.WriteString(fmt.Sprintf("- %s price series", kline.PrimaryTimeframe))
if kline.EnableMultiTimeframe {
sb.WriteString(fmt.Sprintf(" + %s K-line series\n", kline.LongerTimeframe))
} else {
sb.WriteString("\n")
}
} else {
sb.WriteString(fmt.Sprintf("- %s price series", kline.PrimaryTimeframe))
if kline.EnableMultiTimeframe {
sb.WriteString(fmt.Sprintf(" + %s K-line series\n", kline.LongerTimeframe))
} else {
sb.WriteString("\n")
}
}
if indicators.EnableEMA {
sb.WriteString("- EMA indicators")
sb.WriteString("- " + label("EMA indicators", "EMA indicators"))
if len(indicators.EMAPeriods) > 0 {
sb.WriteString(fmt.Sprintf(" (periods: %v)", indicators.EMAPeriods))
sb.WriteString(fmt.Sprintf(" (%s: %v)", label("periods", "periods"), indicators.EMAPeriods))
}
sb.WriteString("\n")
}
if indicators.EnableMACD {
sb.WriteString("- MACD indicators\n")
sb.WriteString("- " + label("MACD indicators", "MACD indicators") + "\n")
}
if indicators.EnableRSI {
sb.WriteString("- RSI indicators")
sb.WriteString("- " + label("RSI indicators", "RSI indicators"))
if len(indicators.RSIPeriods) > 0 {
sb.WriteString(fmt.Sprintf(" (periods: %v)", indicators.RSIPeriods))
sb.WriteString(fmt.Sprintf(" (%s: %v)", label("periods", "periods"), indicators.RSIPeriods))
}
sb.WriteString("\n")
}
if indicators.EnableATR {
sb.WriteString("- ATR indicators")
sb.WriteString("- " + label("ATR indicators", "ATR indicators"))
if len(indicators.ATRPeriods) > 0 {
sb.WriteString(fmt.Sprintf(" (periods: %v)", indicators.ATRPeriods))
sb.WriteString(fmt.Sprintf(" (%s: %v)", label("periods", "periods"), indicators.ATRPeriods))
}
sb.WriteString("\n")
}
if indicators.EnableBOLL {
sb.WriteString("- Bollinger Bands (BOLL) - Upper/Middle/Lower bands")
sb.WriteString("- " + label("Bollinger Bands (BOLL) - Upper/Middle/Lower bands", "Bollinger Bands (BOLL) - Upper/Middle/Lower bands"))
if len(indicators.BOLLPeriods) > 0 {
sb.WriteString(fmt.Sprintf(" (periods: %v)", indicators.BOLLPeriods))
sb.WriteString(fmt.Sprintf(" (%s: %v)", label("periods", "periods"), indicators.BOLLPeriods))
}
sb.WriteString("\n")
}
if indicators.EnableVolume {
sb.WriteString("- Volume data\n")
sb.WriteString("- " + label("Volume data", "Volume data") + "\n")
}
if indicators.EnableOI {
sb.WriteString("- Open Interest (OI) data\n")
sb.WriteString("- " + label("Open Interest (OI) data", "Open Interest (OI) data") + "\n")
}
if indicators.EnableFundingRate {
sb.WriteString("- Funding rate\n")
sb.WriteString("- " + label("Funding rate", "Funding rate") + "\n")
}
if len(e.config.CoinSource.StaticCoins) > 0 || e.config.CoinSource.UseAI500 || e.config.CoinSource.UseOITop {
sb.WriteString("- AI500 / OI_Top filter tags (if available)\n")
sb.WriteString("- " + label("AI500 / OI_Top filter tags (if available)", "AI500 / OI_Top filter tags (if available)") + "\n")
}
if indicators.EnableQuantData {
sb.WriteString("- Quantitative data (institutional/retail fund flow, position changes, multi-period price changes)\n")
sb.WriteString("- " + label("Quantitative data (institutional/retail fund flow, position changes, multi-period price changes)", "Quantitative data (institutional/retail fund flow, position changes, multi-period price changes)") + "\n")
}
}
@@ -277,13 +807,13 @@ func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string {
}
if lang == LangChinese {
sb.WriteString("## 历史交易统计\n")
sb.WriteString(fmt.Sprintf("总交易: %d 笔 | 盈利因子: %.2f | 夏普比率: %.2f | 盈亏比: %.2f\n",
sb.WriteString("## Historical Trading Statistics\n")
sb.WriteString(fmt.Sprintf("Total Trades: %d | Profit Factor: %.2f | Sharpe: %.2f | Win/Loss Ratio: %.2f\n",
ctx.TradingStats.TotalTrades,
ctx.TradingStats.ProfitFactor,
ctx.TradingStats.SharpeRatio,
winLossRatio))
sb.WriteString(fmt.Sprintf("总盈亏: %+.2f USDT | 平均盈利: +%.2f | 平均亏损: -%.2f | 最大回撤: %.1f%%\n",
sb.WriteString(fmt.Sprintf("Total PnL: %+.2f USDT | Avg Win: +%.2f | Avg Loss: -%.2f | Max Drawdown: %.1f%%\n",
ctx.TradingStats.TotalPnL,
ctx.TradingStats.AvgWin,
ctx.TradingStats.AvgLoss,
@@ -291,13 +821,13 @@ func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string {
// Performance hints based on profit factor, sharpe, and drawdown
if ctx.TradingStats.ProfitFactor >= 1.5 && ctx.TradingStats.SharpeRatio >= 1 {
sb.WriteString("表现: 良好 - 保持当前策略\n")
sb.WriteString("Performance: GOOD - maintain current strategy\n")
} else if ctx.TradingStats.ProfitFactor < 1 {
sb.WriteString("表现: 需改进 - 提高盈亏比,优化止盈止损\n")
sb.WriteString("Performance: NEEDS IMPROVEMENT - improve win/loss ratio, optimize TP/SL\n")
} else if ctx.TradingStats.MaxDrawdownPct > 30 {
sb.WriteString("表现: 风险偏高 - 减少仓位,控制回撤\n")
sb.WriteString("Performance: HIGH RISK - reduce position size, control drawdown\n")
} else {
sb.WriteString("表现: 正常 - 有优化空间\n")
sb.WriteString("Performance: NORMAL - room for optimization\n")
}
} else {
sb.WriteString("## Historical Trading Statistics\n")
@@ -368,6 +898,11 @@ func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string {
sb.WriteString(e.formatQuantData(quantData))
}
}
if ctx.VergexDataMap != nil {
if vergexData, hasVergex := ctx.VergexDataMap[coin.Symbol]; hasVergex {
sb.WriteString(e.formatVergexData(vergexData))
}
}
sb.WriteString("\n")
}
sb.WriteString("\n")
@@ -394,7 +929,7 @@ func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string {
}
sb.WriteString("---\n\n")
sb.WriteString("Now please analyze and output your decision (Chain of Thought + JSON)\n")
sb.WriteString("Now please analyze briefly and output the decision JSON.\n")
return sb.String()
}
@@ -433,6 +968,11 @@ func (e *StrategyEngine) formatPositionInfo(index int, pos PositionInfo, ctx *Co
sb.WriteString(e.formatQuantData(quantData))
}
}
if ctx.VergexDataMap != nil {
if vergexData, hasVergex := ctx.VergexDataMap[pos.Symbol]; hasVergex {
sb.WriteString(e.formatVergexData(vergexData))
}
}
sb.WriteString("\n")
}
@@ -491,11 +1031,26 @@ func (e *StrategyEngine) formatCoinSourceTag(sources []string) string {
return " (Hyperliquid All)"
case "hyper_main":
return " (Hyperliquid Top20)"
case "vergex_signal":
return " (Vergex Signal)"
}
if strings.HasPrefix(sources[0], "hyper_rank") {
return " (Hyperliquid Dynamic Rank)"
}
}
return ""
}
func (e *StrategyEngine) formatVergexData(data *vergex.MarketAnalysis) string {
if data == nil {
return ""
}
var sb strings.Builder
sb.WriteString("\nVergex Claw402 Signals:\n")
sb.WriteString(vergex.FormatAnalysisForAI(data))
return sb.String()
}
// ============================================================================
// Market Data Formatting
// ============================================================================

Some files were not shown because too many files have changed in this diff Show More