Compare commits

..

170 Commits

Author SHA1 Message Date
shokollm
2d125ede22 fix: Also fix price_change field in AI tool execution path
There was duplicate code handling search_tokens in the AI tool calling section.
2026-04-14 01:43:30 +00:00
shokollm
7a64632a63 fix: Correct price_change field fallback logic
Was returning 'N/A' incorrectly when token_price_change_24h was missing.
Now properly checks: price_change_24h OR token_price_change_24h OR 'N/A'
2026-04-14 00:37:48 +00:00
shokollm
bb62e53093 fix: Handle price_change_24h field name in search results
Search API returns 'price_change_24h' not 'token_price_change_24h'. Now checks for both.
2026-04-14 00:33:38 +00:00
shokollm
cf74251ad0 fix: Show token name/symbol in risk analysis and handle unknown honeypot
- Display token name and symbol in risk analysis output
- Handle is_honeypot: -1 as 'Unknown (could not determine)'
- Show risk level (Low/Medium/High) with risk score
- Use risk_level field instead of status
2026-04-14 00:28:15 +00:00
shokollm
1efc0eaba6 feat: Add context awareness for price tool
- Store recent search results in agent instance
- When price returns empty, suggest using /search tool
- Check if user input matches recent search results and use that address
2026-04-14 00:19:42 +00:00
shokollm
f4f6168f68 revert: Keep using price API for price lookups
The price API requires full contract addresses (0x...-bsc format).
Improved error handling and formatting for price responses.
2026-04-14 00:12:46 +00:00
shokollm
62bcd6e099 fix: Use search API for price lookups instead of price API
The price API requires full contract addresses (0x...-bsc), but users typically provide symbols.
Now /price TRADOOR will search for the token and show price info from search results.
2026-04-14 00:03:34 +00:00
shokollm
6b8912a7eb fix: Better error detection for AVE API commands
- Added _is_error_output helper to detect errors in CLI output
- API errors like 'API error 403' now show proper error message instead of 'No price data available'
- Updated all command execution methods to use the helper
2026-04-13 23:55:51 +00:00
shokollm
2c3b6ef073 fix: Show token name and ticker in backtest result
- Added _get_token_info helper to fetch symbol and name
- Backtest result now shows: **PEPE** (Pepe) -
2026-04-13 23:37:17 +00:00
shokollm
613ec0dc1f fix: Provide default empty string for backtest/simulate calls
- Fixed missing message argument when calling direct execution methods
- Both /backtest and /simulate now work without arguments
2026-04-13 23:34:04 +00:00
shokollm
7bdd49a56c fix: Execute backtest and simulate commands directly
- Added _execute_backtest_direct() that extracts params from message or strategy config
- Added _execute_simulate_direct() that extracts params from message or strategy config
- Both commands now execute directly when called with /backtest or /simulate
- If token address is missing, asks user for the parameter
2026-04-13 23:32:08 +00:00
shokollm
e92506a787 feat: Two-step command execution flow
Commands now execute in two steps:
1. User sends /search -> acknowledge and wait for param
2. User sends 'pepe' -> auto-execute search with 'pepe'

Commands without params (/backtest, /simulate, /trending, /strategy) execute directly.

Pending commands tracked via self.pending_command state.
2026-04-13 23:23:01 +00:00
shokollm
696d3934d5 fix: Execute trending command directly when /trending is called
- Added _execute_trending method that runs the trending CLI command
- Returns formatted list of trending tokens on BSC
- Shows error message if no tokens found or command fails
2026-04-13 23:07:43 +00:00
shokollm
466fdf1fe9 fix: Fetch strategy from database when /strategy is called
- Added _get_strategy_response method to query bot's strategy_config from DB
- Shows formatted strategy with conditions, actions, and risk management
- Shows helpful message if no strategy is configured yet
2026-04-13 23:02:49 +00:00
shokollm
39a27caf05 feat: Add slash command system with skill acknowledgments
- Reset conversational.py to pr-58 working AVE integration
- Added TOOL_REGISTRY with all available slash commands
- Added _handle_slash_command for skill activation
- Slash commands show brief acknowledgment when used alone
- Slash commands with args are passed to AI for handling
- Added dropdown menu in ChatInterface for skill discovery
- Menu positions above textarea
- Menu shows filtered tools as user types
2026-04-13 16:21:57 +00:00
shokollm
61b9da295b feat: Add /trending tool for popular tokens 2026-04-13 13:51:17 +00:00
shokollm
38e45b9fd0 fix: Use 'Commands:' instead of 'Usage:' to match issue spec 2026-04-13 13:48:17 +00:00
shokollm
e41d07486b feat: Add slash command help system for conversational interface
Implement slash command help system as described in issue #57:

- Add Tool Registry in backend with metadata for all available tools
- Add command parser for '/' prefix in ConversationalAgent
- Add slash command handling functions:
  - '/' shows list of all available tools
  - '/help' shows general help about Randebu
  - '/<tool-name>' shows detailed help for specific tool
- Update frontend ChatInterface to detect '/' and show formatted help dropdown
- Add keyboard navigation (Arrow keys, Tab, Enter, Escape) for slash menu
2026-04-13 13:05:08 +00:00
7e03101e7b Merge pull request 'feat: Add AVE Cloud Skills as conversational agent tools (#56)' (#58) from fix/issue-56 into main 2026-04-13 14:56:33 +02:00
shokollm
70dfba2ffc Merge fixes from pr-58 2026-04-13 12:53:44 +00:00
shokollm
6d204b537d feat: add AVE Cloud Skills integration for conversational agent
- Add subprocess-based AVE Cloud Skills CLI integration for token data
- Add new tools: search_tokens, get_token, get_price, get_risk, get_trending
- Add get_trending tool for trending tokens on BSC
- Replace direct API calls with CLI subprocess calls

Bug fixes:
- Fix repo_root path calculation (5 → 6 dirname() calls)
- Handle API response where data is a list instead of dict
- Add defensive type checking for all API responses
- Handle string values in market_cap and volume formatting
- Handle None values before calling len()
- Handle nested token data structure (data.token) and alternative field names
- Handle integer/bool types for honeypot check (1/0 vs true/false)
- Handle -1 as unknown for honeypot status
- Show raw API response for debugging when data is missing
2026-04-13 12:47:49 +00:00
shokollm
2b7f54703e refactor: use subprocess to call ave-cloud-skill CLI scripts
Instead of importing library functions directly, now calling the
ave_data_rest.py CLI script via subprocess. This follows the
recommended approach from the SKILL.md documentation.

Changes:
- Add _call_ave_script helper method for subprocess calls
- Update search_tokens, get_token, get_price, get_risk to use CLI
- Set AVE_USE_DOCKER=false to run scripts directly without Docker
- Remove direct imports of ave.http module
2026-04-13 10:24:42 +00:00
shokollm
99dded8d16 feat: add AVE Cloud Skills integration for conversational agent tools
- Add ave-cloud-skill as git submodule
- Create symlink for Python import at src/backend/app/ave
- Replace search_tokens with new library-based implementation
- Add get_token, get_price, get_risk tools using ave-cloud-skill library
- Update TOOLS array and SYSTEM_PROMPT_WITH_TOOLS

Implements issue #56
2026-04-13 09:55:04 +00:00
shokollm
29b7634c34 fix: import Simulation at module level 2026-04-12 14:51:50 +00:00
shokollm
fd5c2b56d7 fix: import Simulation model in _manage_simulation 2026-04-12 14:47:32 +00:00
shokollm
632e1bf524 feat: add simulation management as agent skill
Agent can now manage simulations via conversation:
- 'start' (run simulation on token)
- 'stop' (stop running simulation)
- 'status' (check if running, show progress)
- 'results' (get current/latest simulation results)

Example conversations:
- 'Run a simulation on GRIAN' → starts simulation
- 'How's the simulation going?' → shows status
- 'Stop the simulation' → stops and shows results
- 'Show me the results' → shows simulation results
2026-04-12 14:21:32 +00:00
shokollm
5ae1165ad9 fix: import asyncio in _execute_backtest method 2026-04-12 14:07:52 +00:00
shokollm
283573f5a8 feat: add backtest as agent skill
Agent can now run backtests via conversation:
- User can say 'backtest this strategy' or 'test on [token]'
- Agent extracts token address (from conversation or asks user)
- Agent runs backtest and presents results in chat with:
  - Total Return
  - Final Balance
  - Win Rate
  - Max Drawdown
  - Sharpe Ratio
- Agent suggests strategy adjustments based on results
2026-04-12 13:39:54 +00:00
shokollm
90fa66bd39 feat: add refresh button to simulation page
User can now click 'Refresh' to update simulation data (portfolio, signals, trade log) without reloading the page.
2026-04-12 08:39:55 +00:00
shokollm
84d8a6f4a6 fix: add portfolio to SimulationResponse schema
Portfolio data was being saved to DB but not returned in API responses.
2026-04-12 08:15:14 +00:00
shokollm
a8e0baf0c0 fix: save portfolio data to database
Portfolio (cash balance, position, etc.) is now saved to DB
during simulation so it persists and shows in frontend.
2026-04-12 07:41:56 +00:00
shokollm
6c39e4e89d fix: correctly update cash balance when selling
When selling, the sale proceeds (quantity * price) are now added to current_balance.
This ensures:
- Cash decreases when buying
- Cash increases when selling (including stop loss / take profit)
- Portfolio P&L is calculated correctly
2026-04-12 07:24:49 +00:00
shokollm
bba773251a feat: add portfolio summary to simulation page
Shows real-time portfolio metrics:
- Cash Balance
- Position (quantity and value)
- Entry Price / Current Price
- Unrealized P&L
- Total Value
- P&L (absolute and percentage)

Updates as simulation runs and trades are executed.
2026-04-12 07:15:11 +00:00
shokollm
3013326ded feat: add time labels to X axis of price chart
- Shows time (HH:MM) at 5 points along the X axis
- Legend moved up to make room for time labels
- More bottom padding for better display
2026-04-12 07:07:19 +00:00
shokollm
a82185de60 fix: syntax error in simulate.py finally block 2026-04-12 06:18:11 +00:00
shokollm
cadea23e40 fix: respect candle_delay from config, default to fast tests
- Tests now run with candle_delay=0 for fast execution
- Simulation defaults to candle_delay based on interval (e.g., 30s for 1m)
- Progress saved to DB every 5 seconds during simulation
- User can now see real-time updates while simulation runs

Tests: 14 passing in 0.15s
2026-04-12 05:24:43 +00:00
shokollm
984656c83c test: add full integration test for simulation
test_full_simulation_workflow_generates_signals_and_trades:
- Creates klines with clear price movements
- Uses very low threshold (0.1%) to ensure signals generated
- Verifies signals are NOT empty
- Verifies trade_log is NOT empty
- Verifies BUY signals are generated
- Verifies results contain signals and trade_log

This test ensures the simulation engine is working correctly.
2026-04-12 05:00:46 +00:00
shokollm
1505bc9913 fix: serialize datetime objects to ISO format for JSON storage
The signals contain datetime objects for created_at which can't be serialized to JSON directly. Convert them to ISO format strings before storing.
2026-04-12 04:50:24 +00:00
shokollm
dd61c32ea7 feat: add trade activity dashboard
Shows what happened at each candle:
- BUY/SELL/HOLD actions
- Price at that time
- Reason for action
- Entry price for positions

Trade log is stored in DB and displayed in frontend.
2026-04-12 04:28:40 +00:00
shokollm
01ec8bc539 fix: make SignalChart more robust
- Use ResizeObserver to handle width changes
- Use tick() to ensure DOM is ready before drawing
- Access reactive values in effect to trigger on changes
- Fixed canvas sizing to use percentage width
2026-04-12 04:11:34 +00:00
shokollm
a253aae766 fix: limit klines to 1 hour, fix chart parsing string to number
- Kline data now fetches only last hour of data
- SignalChart converts string 'close' prices to numbers
2026-04-12 03:57:22 +00:00
shokollm
13e899c851 fix: fetch klines synchronously before returning response
The background task wasn't completing before the response was returned.
Now we fetch klines synchronously (await) before returning the simulation.
2026-04-12 03:51:03 +00:00
shokollm
384f84e772 fix: fetch klines synchronously so chart shows immediately
The simulation engine completes in seconds, but the background task wasn't
saving klines to DB. Now we fetch klines first synchronously so the user
can see the price chart immediately after starting a simulation.

Also marks any stuck 'running' simulations as 'stopped'.
2026-04-12 03:18:42 +00:00
shokollm
cd1a41d1d7 feat: show price chart even when no signals 2026-04-12 03:02:51 +00:00
shokollm
6a20cc174f feat: add price chart to simulation and unit tests
Unit tests (13 passing):
- Kline fetching and processing
- Price drop condition triggers buy
- Stop loss and take profit risk management
- Multiple positions (buy again after sell)
- Max candles limit
- Stop interruption handling

Frontend:
- SignalChart now shows price movement even before signals
- Shows candle count even with no signals
- Chart displays buy/sell markers when signals exist
- Canvas-based chart with gradient fill

Backend:
- Simulation stores klines for chart display
- Returns klines in API response
- Simplified simulation run (no periodic saving)
2026-04-12 02:42:52 +00:00
shokollm
ce8a29c0a4 fix: update notice message for klines-based simulation 2026-04-12 02:22:17 +00:00
shokollm
f425ae08d7 feat: klines-based simulation instead of polling
- Fetch historical klines once from AVE API (10 CU per request)
- Process each candle as a time step
- Limit to 500 candles max per simulation
- No continuous polling - processes all data in seconds
- Frontend now selects kline interval (1m, 5m, 15m, 1h)
- Much more efficient API usage
2026-04-12 01:34:20 +00:00
shokollm
d4400f5dcd fix: simulation conditions now check properly from first iteration
The first price check was always skipped because last_price was None.
Now first iteration primes last_price, subsequent iterations check conditions.
2026-04-12 00:53:05 +00:00
shokollm
1591fcb1ca fix: remove check_interval restriction for non-pro plans
Simulation is paper trade only, so no need to restrict check_interval.
Allow 10 second intervals for all users.
2026-04-12 00:23:55 +00:00
shokollm
b0131aa566 fix: stop simulation always updates DB status
- Status was only updated if engine was in memory (race condition)
- Now always sets status to 'stopped' in DB
- Returns 'stopped' instead of 'stopping'
- Cleaned up 3 stale running simulations in DB
2026-04-12 00:15:41 +00:00
shokollm
52adc93b25 fix: show running simulation correctly, stop old ones when starting new
Frontend:
- Load simulations now prioritizes running simulation over most recent
- Clear signals before loading new simulation

Backend:
- When starting new simulation, stop any existing running simulation first
- Previously would return existing running simulation (confusing UX)
2026-04-12 00:10:10 +00:00
shokollm
79c3ec7d16 fix: typo in simulate page svelte 2026-04-12 00:00:47 +00:00
shokollm
3505cf4ade refactor: simplify simulation to run forever as paper trade
- No duration limit - runs forever until user stops
- Only 1 running simulation per bot (returns existing if already running)
- Always paper trade (no auto-execute option)
- Removed Pro upgrade banner
- Removed duration and auto-execute config options
- Simplified API to only require token, chain, check_interval
2026-04-11 23:52:00 +00:00
shokollm
1b1358353f feat: configurable simulation duration and periodic signal saving
Frontend:
- Added duration selector (1min, 5min, 10min, 30min)
- Added check interval selector (10s, 30s, 60s)

Backend:
- Signals are now saved to database every 30 seconds during simulation
- Can stop simulation early to see partial signals
2026-04-11 17:56:27 +00:00
shokollm
726e579f5f fix: get_token_price checking wrong status code
The AVE API returns status: 1 for success, not status: 200.
This was causing get_token_price to always return None, resulting
in no signals being generated during simulation.
2026-04-11 17:45:59 +00:00
shokollm
b111e4d79f fix: make SimulateEngine.stop() synchronous
The stop() method was async but called from a sync context,
causing 'RuntimeError: no running event loop'. Changed to sync
since it just sets flags.
2026-04-11 17:35:18 +00:00
shokollm
0d63a10ac8 fix: correct simulation API field names
The backend expects 'check_interval' not 'interval_seconds',
and 'chain' is required.
2026-04-11 17:22:45 +00:00
shokollm
19f28fc599 feat: use token from strategy config in simulation page
Like the backtest page, simulation now extracts the token from the
bot's strategy config instead of requiring user input. Shows token
name and truncated address.
2026-04-11 17:17:26 +00:00
shokollm
5f7667992e feat: display backtest config in history card
Show token, timeframe, and date range for each backtest in the history list:
- Token: PEPE
- TF: 1h
- Period: 2026-03-11 to 2026-04-09
2026-04-11 17:11:24 +00:00
shokollm
cd4583ca90 feat: add pagination for trade history in backtest
Backend:
- Added pagination to /trades endpoint with page and per_page params
- Returns paginated trades with metadata (page, total_pages, has_next, has_prev)

Frontend:
- Added pagination controls for trade history (Prev/Next buttons)
- Shows current page info and total trades
- Trades are loaded on-demand when expanded

API changes:
- GET /bots/{id}/backtest/{runId}/trades?page=1&per_page=5
- Response includes: trades, total_trades, page, per_page, total_pages, has_next, has_prev
2026-04-11 16:52:45 +00:00
shokollm
6cadb7a67b test: verify stop loss always results in loss
Add test case that ensures when stop loss is triggered after multiple
DCA buys with decreasing prices, the final balance is always less
than the initial balance.
2026-04-11 16:27:04 +00:00
shokollm
02e0b0ccab fix: proper DCA and max_drawdown calculations in backtest engine
Three bugs fixed:

1. **Weighted average entry price for risk management**:
   - Previously, entry_price was overwritten on each buy, causing stop loss
     to be calculated from the latest buy price instead of average
   - Added cost_basis tracking and average_entry_price property
   - Stop loss now correctly uses weighted average across all buys

2. **Portfolio value accumulation in _calculate_metrics**:
   - Bug: running_position = trade['quantity'] was OVERWRITING position
   - Fix: running_position += trade['quantity'] to properly accumulate DCA

3. **Risk management exit reset**:
   - Added cost_basis reset when position is closed

Max drawdown is now correctly bounded by stop loss percentage (~5%)
instead of showing inflated values like 59%.
2026-04-11 15:54:15 +00:00
shokollm
29ec67cced fix: handle floating point precision in take_profit check and final_balance calculation
Two bugs fixed:
1. final_balance was incorrectly calculated as balance + balance when position=0 due to expression structure
2. take_profit check needed epsilon for floating point precision (95 * 1.10 = 104.50000000000001 instead of 104.5)
2026-04-11 15:02:53 +00:00
shokollm
c86e71c3a3 fix: correct final_balance calculation in _calculate_metrics
Bug: The expression was evaluating incorrectly due to operator precedence:
  final_balance = balance + (position * price if condition else balance)

When condition=False (position=0), this became: balance + balance = 2x balance!

Fixed by restructuring to if/else block.
2026-04-11 15:00:52 +00:00
shokollm
44fb840731 fix: correctly track balance in portfolio value calculation for max_drawdown
The bug was that running_balance was set to trade['amount'] which is
the amount SPENT on a buy (not remaining balance), causing inflated
portfolio values and incorrect max drawdown calculation.

Now properly tracks:
- After BUY: balance decreases by amount spent
- After SELL: balance increases by amount received
2026-04-11 14:22:47 +00:00
shokollm
6a5694f74b fix: properly value open positions using last kline price for max_drawdown calculation
- Track last_kline_price during kline processing
- Use last_kline_price instead of entry price for open position valuation
- Add final marked-to-market value to portfolio_values for max_drawdown calculation
- This fixes the issue where max_drawdown exceeded stop_loss percentage
2026-04-11 13:54:16 +00:00
shokollm
680a9322e3 debug: add logging to trace strategy_config in backtest engine 2026-04-11 11:59:37 +00:00
shokollm
9973b8f6e2 feat: make trade history expandable with button 2026-04-11 06:49:58 +00:00
shokollm
30476e782b fix: remove duplicate backtest history section 2026-04-11 06:23:35 +00:00
shokollm
02ca452655 feat: show trades inline in backtest history 2026-04-11 06:16:10 +00:00
shokollm
cb9558d54f feat: show trades inline in backtest card instead of modal 2026-04-11 06:08:43 +00:00
shokollm
638e17eb73 debug: simplify modal to show raw JSON 2026-04-11 05:48:33 +00:00
shokollm
69a8b06462 debug: add debug info to see selectedTrades.length 2026-04-11 05:44:08 +00:00
shokollm
15e72b009c debug: add console logs to viewTrades function 2026-04-11 05:39:49 +00:00
shokollm
19ba0c7cc6 fix: parse JSON string result if needed when retrieving trades 2026-04-11 05:36:47 +00:00
shokollm
847890b634 feat: limit backtest history to latest 5 2026-04-11 05:36:31 +00:00
shokollm
6658a418cc fix: missing newline in backtest.py causing 404 2026-04-11 05:26:51 +00:00
shokollm
5c9e46e693 feat: add trades history modal to backtest page 2026-04-11 05:18:23 +00:00
shokollm
194c4f8a62 fix: use original datetime for created_at instead of converted string 2026-04-11 05:06:21 +00:00
shokollm
7afcb983e8 fix: correct klines status check (1 not 200) and data.points format 2026-04-11 04:56:50 +00:00
shokollm
caef4b36ed feat: auto-fill token from strategy config in backtest page 2026-04-11 04:37:52 +00:00
shokollm
3bf2877df2 fix: append -bsc suffix to token address for klines API 2026-04-10 17:07:14 +00:00
shokollm
145c6710d1 fix: set 1-day range for backtest (start day before end day) 2026-04-10 16:30:22 +00:00
shokollm
3c8c85aefc fix: table regex to match tables anywhere in text (not just at start) 2026-04-10 13:53:25 +00:00
shokollm
39b2b558a5 fix: export parseInlineElements and types from markdown.ts 2026-04-10 13:17:07 +00:00
shokollm
7795753aaa fix: render bold and inline code formatting in list items 2026-04-10 13:14:17 +00:00
shokollm
36dcfdb6e2 chore: restrict agent to BSC only, remove chain parameter from search_tokens tool 2026-04-10 12:58:25 +00:00
shokollm
48fc323dac fix: handle native tool_calls from MiniMax API instead of parsing JSON from content 2026-04-10 12:54:29 +00:00
shokollm
0af2de7209 feat: add search_tokens tool for AI to recommend trending tokens 2026-04-10 12:48:49 +00:00
shokollm
e82b8b3549 fix: update token search to use trending endpoint (v2/tokens doesn't exist) 2026-04-10 12:37:27 +00:00
shokollm
6f23b322d3 feat: add token search in modal when confirming address 2026-04-10 12:14:32 +00:00
shokollm
297a185215 feat: implement token address confirmation dialog and limit backtest duration 2026-04-10 11:52:40 +00:00
shokollm
f86ff75525 fix: remove extra closing brace in CSS 2026-04-10 11:32:11 +00:00
shokollm
6f9564790f docs: add ISSUES.md for tracking open issues 2026-04-10 11:13:20 +00:00
shokollm
f43eb11f6f feat: improve backtest with manual refresh and token address confirmation 2026-04-10 10:54:42 +00:00
shokollm
446da96ce4 fix: search for token first to get proper token_id before fetching klines 2026-04-10 10:47:33 +00:00
shokollm
922ef89c1e feat: add backtest progress tracking and fix stop functionality 2026-04-10 10:43:04 +00:00
shokollm
a601ebb08b fix: handle datetime serialization in backtest and show errors in frontend 2026-04-10 10:34:29 +00:00
shokollm
bb40193fc3 fix: add required chain field (bsc) to backtest request 2026-04-10 10:28:16 +00:00
shokollm
3a7d3a3732 feat: set default dates for backtest (yesterday to 30 days ago) 2026-04-10 10:23:35 +00:00
shokollm
0f558a5e8e fix: handle array error format from FastAPI validation errors 2026-04-10 10:21:27 +00:00
shokollm
9e9ff6fa7f fix: handle undefined timeframe in strategy preview 2026-04-10 10:19:28 +00:00
shokollm
4c48932ece fix: support inline formatting in table cells (bold, italic, code, links) 2026-04-10 10:15:21 +00:00
shokollm
bfc85648db fix: improve markdown parser for tables, headings, and line breaks 2026-04-10 10:09:46 +00:00
shokollm
925920eee1 fix: add typing indicator back when waiting for response 2026-04-10 10:05:50 +00:00
shokollm
299e74cffa chore: hide ProUpgradeBanner (not implementing pro yet) 2026-04-10 09:59:08 +00:00
shokollm
2b875cfa27 feat: show thinking above response with expand/collapse, first line preview 2026-04-10 09:56:21 +00:00
shokollm
ae612ad725 fix: use requests instead of OpenAI client for thinking endpoint 2026-04-10 09:50:36 +00:00
shokollm
08912019c2 feat: use MiniMax extended thinking endpoint for native reasoning 2026-04-10 09:47:09 +00:00
shokollm
44453877b3 feat: use direct LLM with structured JSON for thinking/response separation 2026-04-10 09:31:07 +00:00
shokollm
ad4a1e89d5 fix: revert to kickoff (stream not available on Agent) 2026-04-10 09:23:46 +00:00
shokollm
57fa200ba9 feat: add thinking content to chat response 2026-04-10 09:16:08 +00:00
shokollm
db4fb83243 feat: add markdown rendering and thinking state UI to chat 2026-04-10 09:01:16 +00:00
shokollm
560b61c431 fix: increase timeout for long-running AI chat requests 2026-04-10 08:51:03 +00:00
shokollm
c6baadf8b8 fix: use JSON body for login instead of form data 2026-04-10 08:09:42 +00:00
shokollm
937cc2da60 fix: send username instead of email for login API 2026-04-10 07:47:21 +00:00
32cd7184ea Merge pull request 'feat: implement conversational AI agent with tool-calling' (#52) from feat/conversational-agent-with-tools into main 2026-04-10 09:39:45 +02:00
shokollm
765e390b9b feat: implement conversational AI agent with tool-calling
Prototype implementation that allows:
1. Normal conversation with the AI
2. Tool-calling to update trading strategies

Created new ConversationalAgent that uses CrewAI with tools:
- get_current_strategy: Check current bot strategy
- update_trading_strategy: Update bot's trading configuration

The agent can now respond to questions like 'What is this?' without
forcing JSON output, and can update strategies when user provides
specific parameters.

Refs #51
2026-04-10 05:00:22 +00:00
21ce282cae Merge pull request 'fix: add fallback UUID generator for crypto.randomUUID compatibility' (#50) from fix/crypto-randomuuid-fallback into main 2026-04-10 06:26:49 +02:00
shokollm
4fa9b0456a fix: add fallback UUID generator for crypto.randomUUID compatibility
crypto.randomUUID() is not available in all environments (e.g., older browsers,
non-secure contexts). Added a fallback UUID v4 implementation.
2026-04-10 04:19:45 +00:00
af9900d0ba Merge pull request 'fix: add timeout for chat requests and improve error handling' (#49) from fix/chat-timeout-handling into main 2026-04-10 06:15:45 +02:00
shokollm
b3ab004447 fix: add timeout for chat requests and improve error handling
Changes:
1. Add 30-second timeout for chat API requests using AbortController
2. User's message now shows immediately before API response (already done in previous PR)
3. Differentiate between timeout errors and other errors in error messages
4. API client now accepts optional signal parameter for abort support
2026-04-10 04:09:30 +00:00
d394bc0857 Merge pull request 'fix: display user messages in chat interface' (#48) from fix/display-user-messages into main 2026-04-10 06:04:23 +02:00
shokollm
dfa806ab53 fix: add user's message to frontend chat store when sending
Previously, only the assistant's response was added to the frontend store.
Now both user and assistant messages are stored, so the conversation
displays correctly in the chat interface.
2026-04-10 04:00:51 +00:00
3493775b7f Merge pull request 'fix: update MiniMaxConnector default model to MiniMax-M2.7' (#47) from fix/minimax-connector-model into main 2026-04-10 06:00:36 +02:00
shokollm
82645dfb3b fix: update MiniMaxConnector default model to MiniMax-M2.7 2026-04-10 03:53:26 +00:00
c17fa243a1 Merge pull request 'fix: use MiniMax text/chatcompletion_v2 endpoint' (#46) from fix/minimax-endpoint-v2 into main 2026-04-10 05:44:25 +02:00
shokollm
a55ed9cc04 fix: use MiniMax text/chatcompletion_v2 endpoint instead of chat/completions
The /v1/chat/completions endpoint returns 529 (overloaded) while
/v1/text/chatcompletion_v2 works reliably.
2026-04-10 03:42:20 +00:00
d1408b74b4 Merge pull request 'fix: properly configure MiniMax API endpoint and CrewAI LLM' (#45) from fix/minimax-api-endpoint-v2 into main 2026-04-10 05:31:13 +02:00
shokollm
4197475eed fix: properly configure CrewAI LLM with MiniMax api_base
- Use CrewAI's LLM class directly with api_base parameter instead of custom subclass
- Remove broken MiniMaxLLM inheritance from LLM
- Update agent creation to use LLM(model, api_key, api_base) pattern

The issue was that inheriting from CrewAI's LLM class caused the api_base
to be set to None. Now we use CrewAI's LLM directly with the correct parameters.

Fixes #43
2026-04-10 03:19:51 +00:00
87bac8894a Merge pull request 'fix: update MiniMax API endpoint to api.minimax.io' (#44) from fix/minimax-api-endpoint into main 2026-04-10 05:10:17 +02:00
shokollm
bef4479675 fix: update MiniMax API endpoint and default model
Changes:
1. Updated API endpoint from api.minimax.chat to api.minimax.io
2. Changed default model from MiniMax-Text-01 to MiniMax-M2.7
   (MiniMax-Text-01 is not available for all API key plans)
3. Updated .env.example with correct default model

MiniMax API docs: https://platform.minimax.io/docs/api-reference/text-anthropic-api

Fixes #43
2026-04-10 03:07:02 +00:00
75970c57e3 Merge pull request 'feat: return access token on user registration' (#42) from feat/41-return-token-on-register into main 2026-04-10 03:31:15 +02:00
shokollm
f23044465a feat: return access token on user registration
After successful registration, the backend now returns an access token
(along with token_type) so the frontend can:
- Store the token in localStorage
- Fetch the user profile
- Redirect to dashboard

Fixes #41
2026-04-10 01:28:01 +00:00
a6e4d28aa7 Merge pull request 'fix: add bcrypt version constraint for passlib compatibility' (#40) from fix/bcrypt-compatibility into main 2026-04-10 02:55:36 +02:00
shokollm
8693946cb8 fix: add bcrypt version constraint for passlib compatibility
bcrypt 5.0.0 is incompatible with passlib 1.7.x - passlib tries to
access bcrypt.__about__.__version__ which was removed in bcrypt 5.x.

Constrain bcrypt to >=4.0,<5.0 to maintain compatibility.
2026-04-10 00:55:18 +00:00
a2f549c056 Merge pull request 'fix: correct import paths in ai_agent module' (#39) from fix/ai-agent-imports into main 2026-04-09 17:32:21 +02:00
shokollm
ad6e57655d fix: correct import paths in ai_agent module
- Fix relative import path in crew.py (from ..core to ...core)
- Update __init__.py exports to match actual class names
- Remove incorrect CrewAgent and LLMConnector exports
2026-04-09 15:27:09 +00:00
ac5e9d8b81 Merge pull request 'fix: add error logging to simulate engine to prevent silent failures' (#38) from fix/issue-30 into main 2026-04-09 12:19:36 +02:00
shokollm
81f3342365 fix: add error logging to simulate engine to prevent silent failures
Errors during price fetching are now logged and stored in an errors list,
allowing users to see error count/warnings in simulation results.

Acceptance Criteria:
- [x] Errors are logged (not silently swallowed)
- [x] User can see error count/warnings in simulation results
- [x] Simulation completes even if some price fetches fail (graceful degradation)
2026-04-09 10:16:22 +00:00
6adad0701d Merge pull request 'fix: consolidate AveCloudClient to single implementation' (#37) from fix/issue-29 into main 2026-04-09 12:11:59 +02:00
shokollm
405b35c3ba fix: consolidate AveCloudClient to single implementation in services/ave/client.py 2026-04-09 10:06:16 +00:00
dd25d38e7e Merge pull request 'feat: implement stop-loss and take-profit risk management' (#36) from fix/issue-28 into main 2026-04-09 11:39:50 +02:00
shokollm
da8327c0e0 feat: implement stop-loss and take-profit in backtest and simulate engines 2026-04-09 09:14:08 +00:00
8d33ea9a44 Merge pull request 'fix: flatten strategy config schema (backtesting broken)' (#35) from fix/issue-25 into main 2026-04-09 09:32:49 +02:00
shokollm
d81464b869 fix: flatten strategy config schema to match engine expectations
LLM was outputting nested params structure but engines expect flat fields.
This caused backtesting and simulation to never trigger any trades.

Changes:
- llm_connector.py: Update prompt to output flat condition structure
- crew.py: Update StrategyValidator to validate flat structure
- crew.py: Update StrategyExplainer to read flat structure

Fixes #25
2026-04-09 07:31:09 +00:00
55b008d4e8 Merge pull request 'fix: validate chain is 'bsc' for Phase 1' (#34) from fix/issue-31 into main 2026-04-09 09:10:55 +02:00
shokollm
04e4c1a487 fix: validate chain is 'bsc' for BacktestCreate and SimulationCreate 2026-04-09 06:58:16 +00:00
feb65131fa Merge pull request 'fix: populate config endpoints with chain and token data' (#33) from fix/issue-27 into main 2026-04-09 08:23:43 +02:00
shokollm
50af4e0722 fix: reduce tokens limit to 20 per review 2026-04-09 06:18:31 +00:00
shokollm
786e964e32 fix: return bsc chain and tokens from AVE API in config endpoints 2026-04-09 06:02:05 +00:00
41b699f9ee Merge pull request 'fix: make strategy_config and llm_config optional in BotCreate' (#32) from fix/issue-26 into main 2026-04-09 07:54:20 +02:00
shokollm
ccc0404cd1 fix: make strategy_config and llm_config optional in BotCreate schema 2026-04-09 05:30:12 +00:00
shokollm
0a2e347fdb feat: Add database init on startup and documentation
- Add lifespan handler to main.py for automatic DB table creation
- Expand .env.example with detailed variable documentation
- Add AUDIT_REPORT.md with comprehensive product/technical review
- Add STRATEGY_SCHEMA.md as single source of truth for strategy config
- Remove redundant init_db.py script (DB init now handled by app startup)
2026-04-09 04:49:11 +00:00
2561759b78 feat: Add deployment documentation and templates (issue #12) (#23) 2026-04-09 01:23:53 +02:00
b6f99aa8fe Merge pull request '[Backend] AVE Cloud Integration - Data and Trading APIs' (#22) from fix/issue-11 into main 2026-04-08 16:49:13 +02:00
shokollm
3806af3e23 feat(backend): Implement AVE Cloud integration for Data and Trading APIs
- Add tier field to User model for plan detection (free/normal/pro)
- Create AVE Cloud API client with all Data API endpoints:
  - Token search (GET /v2/tokens)
  - Batch prices (POST /v2/tokens/price)
  - Token details (GET /v2/tokens/{id})
  - Kline data (GET /v2/klines/token/{id})
  - Trending tokens (GET /v2/tokens/trending)
  - Token risk (GET /v2/contracts/{id})
- Add Trading API endpoints:
  - Chain wallet quote (POST /v1/chain/quote)
  - Chain wallet swap (POST /v1/chain/swap)
- Add tier gating with upsell messaging for Pro features
- Handle rate limiting gracefully with 429 responses
- Add Pydantic schemas for AVE API requests/responses

Fixes #11
2026-04-08 14:41:40 +00:00
a892a403fb Merge pull request '[Frontend] Components - Chat UI, Dashboard, Visualizations' (#21) from fix/issue-10 into main 2026-04-08 16:23:23 +02:00
shokollm
0bb5d9a5d6 feat: Implement frontend UI components for issue #10
Created the following components:
- ChatInterface: Message input, AI responses, chat history with bot selector dropdown
- BotCard: Bot preview card for dashboard
- BotSelector: Dropdown to select bot (max 3 bots)
- StrategyPreview: Shows parsed strategy config in readable format
- SignalChart: Visual representation of signals over time (SVG-based)
- BacktestChart: Portfolio value chart with metrics display
- ProUpgradeBanner: Upsell banner for Pro features
- TokenPicker: Search/select tokens for conditions
- ConditionBuilder: UI for building trading conditions

Updated pages to use new components:
- Dashboard now uses BotCard
- Bot detail page now uses ChatInterface and StrategyPreview
- Backtest page now uses BacktestChart
- Simulate page now uses SignalChart and ProUpgradeBanner
2026-04-08 13:41:43 +00:00
875427a0c1 Merge pull request '[Frontend] Project Setup - Svelte with TypeScript' (#20) from fix/issue-9 into main 2026-04-08 15:17:10 +02:00
shokollm
5eb623f022 feat: setup SvelteKit frontend with TypeScript
Implemented issue #9 - Frontend Project Setup with Svelte and TypeScript.

Changes:
- Created SvelteKit project with TypeScript
- Set up routing structure:
  - / (landing page)
  - /login
  - /register
  - /dashboard
  - /bot/[id]
  - /bot/[id]/backtest
  - /bot/[id]/simulate
  - /settings
- Created Svelte stores for state management:
  - userStore - Current user info
  - botsStore - List of user's bots
  - currentBotStore - Selected bot
  - chatStore - Chat messages
  - backtestStore - Backtest results
  - simulationStore - Simulation signals
  - authStore - Authentication state
- Created API client for backend communication
- Set up environment variables (.env.example)
- Created auth store with protected routes and login/register functionality
2026-04-08 12:58:33 +00:00
a59a1ccd97 Merge pull request '[Backend] Simulate Engine - Real-time Signal Detection' (#19) from fix/issue-8 into main 2026-04-08 14:57:28 +02:00
shokollm
965efa122b feat: implement Simulate Engine for real-time signal detection
Implement simulate engine for real-time signal detection via REST polling.

Changes:
- SimulateEngine service with configurable check interval (default 60s for free tier)
- REST polling for current prices using AveCloudClient
- Condition matching for real-time data (price_drop, price_rise, volume_spike, price_level)
- Signal logging with user-initiated start/stop
- Simulation API endpoints:
  - POST /api/bots/{id}/simulate - Start simulation
  - GET /api/bots/{id}/simulate/{run_id} - Get status/signals
  - GET /api/bots/{id}/simulations - List all simulations
  - POST /api/bots/{id}/simulate/{run_id}/stop - Stop simulation
- Updated SimulationCreate schema with check_interval field
- Free tier limited to 60s minimum check interval
- Signals stored in database for simulation signal history

Depends on issue #7 (Backtest Engine) which was merged in PR #18
2026-04-08 11:25:51 +00:00
0fb16f06e4 Merge pull request '[Backend] Backtest Engine - Historical Data Replay' (#18) from fix/issue-7 into main 2026-04-08 13:21:56 +02:00
shokollm
a461005015 Implement Backtest Engine - Historical Data Replay
Implements issue #7 - Backtest Engine for historical strategy testing.

Changes:
- Created AveCloudClient for fetching klines from AVE Cloud Data API
- Implemented BacktestEngine with condition matching (price_drop, price_rise, volume_spike, price_level)
- Implemented signal generation and portfolio simulation
- Calculates metrics: total_return, win_rate, max_drawdown, sharpe_ratio, total_trades
- Implemented async/background backtest execution via FastAPI BackgroundTasks
- Stores results in backtests table and signals table
- All backtest API endpoints with JWT auth and ownership validation

API Endpoints:
- POST /api/bots/{id}/backtest - Start backtest
- GET /api/bots/{id}/backtest/{run_id} - Get status/results
- GET /api/bots/{id}/backtests - List all backtests
- POST /api/bots/{id}/backtest/{run_id}/stop - Stop running backtest
2026-04-08 09:39:07 +00:00
b0311bc96f Merge pull request '[Backend] Chat Interface + CrewAI Integration' (#17) from fix/issue-6 into main 2026-04-08 08:37:58 +02:00
shokollm
a280217254 feat: implement chat interface with CrewAI integration
- Create MiniMax LLM connector for CrewAI integration
- Implement TradingCrew with trading_designer, strategy_validator, strategy_explainer
- Add strategy parsing from natural language to strategy_config JSON
- Update chat endpoint with CrewAI integration and conversation context
- Add strategy validation logic
- Add explanation generation for user-friendly responses
- Add BotChatRequest/BotChatResponse schemas

Fixes #6
2026-04-08 06:29:05 +00:00
0cc3327991 Merge pull request '[Backend] Bot CRUD - Bot Management with Max 3 Limit' (#16) from fix/issue-5 into main 2026-04-08 08:16:19 +02:00
shokollm
429d46c6d0 feat: implement bot CRUD with 3-bot limit per user 2026-04-08 06:05:43 +00:00
a2f0c9a0e9 Merge pull request '[Backend] Auth System - JWT Authentication' (#15) from fix/issue-4 into main 2026-04-08 08:01:24 +02:00
80 changed files with 15522 additions and 115 deletions

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "ave-cloud-skill"]
path = ave-cloud-skill
url = https://github.com/AveCloud/ave-cloud-skill.git

1
ave-cloud-skill Submodule

Submodule ave-cloud-skill added at 5eaef99e15

223
deployment/DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,223 @@
# Deployment Guide
This document describes how to deploy the Randebu Trading Bot application to a production server.
## Prerequisites
- Debian server with 8GB RAM, 4 cores
- Python 3.10+
- Node.js 18+
- Nginx
- SSL certificate (Let's Encrypt)
- SSH access to server
## Server Structure
```
/var/www/
└── bot/
├── backend/ # Backend application (FastAPI)
├── frontend/ # Frontend static files (SvelteKit build)
└── data/ # SQLite database and app data
```
## Step-by-Step Deployment
### 1. Clone Repository
```bash
ssh user@your-server
sudo mkdir -p /var/www/bot
sudo chown -R $USER:$USER /var/www/bot
cd /var/www/bot
git clone https://git.example.com/shoko/randebu.git .
```
### 2. Setup Backend
```bash
cd /var/www/bot/src/backend
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
mkdir -p /var/www/bot/data
```
### 3. Configure Environment
Copy and configure the environment file:
```bash
cp src/backend/.env.example /var/www/bot/data/.env
nano /var/www/bot/data/.env
```
Update these values:
- `SECRET_KEY` - Generate a secure key
- `DATABASE_URL` - Update path to `/var/www/bot/data/app.db`
- `MINIMAX_API_KEY` - Your API key
- `AVE_API_KEY` - Your API key
### 4. Build Frontend
```bash
cd /var/www/bot/src/frontend
npm install
npm run build
# Move build to expected location
mkdir -p /var/www/bot/frontend
cp -r build/* /var/www/bot/frontend/
```
### 5. Configure Nginx
Copy the nginx template and modify as needed:
```bash
sudo cp /var/www/bot/deployment/scripts/nginx-template.conf /etc/nginx/sites-available/bot.yourdomain.com
sudo nano /etc/nginx/sites-available/bot.yourdomain.com
```
Update `bot.yourdomain.com` with your actual domain.
Enable the site:
```bash
sudo ln -s /etc/nginx/sites-available/bot.yourdomain.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```
### 6. Setup SSL Certificate
```bash
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d bot.yourdomain.com
```
### 7. Configure Systemd Service
Copy and configure the systemd service:
```bash
sudo cp /var/www/bot/deployment/scripts/systemd-template.service /etc/systemd/system/ave-backend.service
sudo nano /etc/systemd/system/ave-backend.service
```
Update `your-user` and `/var/www/bot` paths as needed.
### 8. Start Backend Service
```bash
sudo systemctl daemon-reload
sudo systemctl enable ave-backend
sudo systemctl start ave-backend
sudo systemctl status ave-backend
```
### 9. Configure Firewall
```bash
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
```
### 10. Verify Deployment
1. Visit `https://bot.yourdomain.com` - should show frontend
2. Visit `https://bot.yourdomain.com/api/...` - should hit backend API
3. Check backend logs: `sudo journalctl -u ave-backend -f`
## Project Structure
```
/var/www/bot/
├── deployment/ # Deployment scripts and templates
│ ├── DEPLOYMENT.md # This file
│ └── scripts/
│ ├── nginx-template.conf
│ ├── systemd-template.service
│ └── deploy.sh # Automated deployment script
├── src/
│ ├── backend/ # FastAPI application
│ │ ├── app/
│ │ │ ├── api/ # API routes
│ │ │ ├── core/ # Core functionality
│ │ │ ├── db/ # Database models
│ │ │ └── services/ # Business logic
│ │ ├── run.py
│ │ └── requirements.txt
│ └── frontend/ # SvelteKit application
│ ├── src/
│ └── package.json
├── data/ # Runtime data (gitignored)
│ ├── app.db # SQLite database
│ └── .env # Environment variables
└── frontend/ # Built frontend static files
```
## Troubleshooting
### Backend won't start
Check logs:
```bash
sudo journalctl -u ave-backend -n 100
```
Common issues:
- Missing environment variables - check `.env` file
- Port 8000 already in use - check configuration
- Database path incorrect - verify paths
### Nginx errors
Test configuration:
```bash
sudo nginx -t
```
Check error logs:
```bash
sudo tail -f /var/log/nginx/error.log
```
### SSL certificate issues
Renew certificate:
```bash
sudo certbot renew
```
Check certificate status:
```bash
sudo certbot certificates
```
## Useful Commands
| Action | Command |
|--------|---------|
| Restart backend | `sudo systemctl restart ave-backend` |
| View backend logs | `sudo journalctl -u ave-backend -f` |
| Check nginx status | `sudo systemctl status nginx` |
| Reload nginx | `sudo systemctl reload nginx` |
| Check port 8000 | `curl http://localhost:8000/health` |
## Rolling Updates
To update the application:
```bash
cd /var/www/bot
git pull
cd src/backend && source venv/bin/activate && pip install -r requirements.txt
sudo systemctl restart ave-backend
```
For frontend updates, rebuild and copy static files to `/var/www/bot/frontend`.

47
deployment/scripts/deploy.sh Executable file
View File

@@ -0,0 +1,47 @@
#!/bin/bash
set -e
DEPLOY_DIR="/var/www/bot"
DOMAIN="bot.yourdomain.com"
GIT_REPO="https://git.example.com/shoko/randebu.git"
BRANCH="main"
echo "=== Randebu Deployment Script ==="
echo "Deploy directory: $DEPLOY_DIR"
echo "Domain: $DOMAIN"
echo ""
cd "$DEPLOY_DIR"
echo "[1/6] Pulling latest code..."
git pull origin "$BRANCH"
echo "[2/6] Updating backend dependencies..."
cd "$DEPLOY_DIR/src/backend"
source venv/bin/activate
pip install -r requirements.txt
echo "[3/6] Rebuilding frontend..."
cd "$DEPLOY_DIR/src/frontend"
npm install
npm run build
mkdir -p "$DEPLOY_DIR/frontend"
cp -r build/* "$DEPLOY_DIR/frontend/"
echo "[4/6] Restarting backend service..."
sudo systemctl restart ave-backend
sleep 2
sudo systemctl status ave-backend --no-pager
echo "[5/6] Testing endpoints..."
curl -s "http://localhost:8000/health" && echo ""
curl -s -o /dev/null -w "Frontend: %{http_code}\n" "https://$DOMAIN/" || true
echo "[6/6] Verifying SSL..."
sudo certbot certificates 2>/dev/null | grep -A2 "$DOMAIN" || echo "No certificate found for $DOMAIN"
echo ""
echo "=== Deployment Complete ==="
echo "Backend: https://$DOMAIN/api/"
echo "Frontend: https://$DOMAIN/"
echo "Backend logs: sudo journalctl -u ave-backend -f"

View File

@@ -0,0 +1,62 @@
server {
listen 80;
server_name bot.yourdomain.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name bot.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/bot.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/bot.yourdomain.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
root /var/www/bot/frontend;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://127.0.0.1:8000/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
proxy_send_timeout 300s;
}
location /ws/ {
proxy_pass http://127.0.0.1:8000/ws/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
}

View File

@@ -0,0 +1,23 @@
[Unit]
Description=Randebu Trading Bot Backend
After=network.target
[Service]
Type=simple
User=your-user
WorkingDirectory=/var/www/bot/src/backend
Environment="PATH=/var/www/bot/src/backend/venv/bin"
ExecStart=/var/www/bot/src/backend/venv/bin/python /var/www/bot/src/backend/run.py
Restart=always
RestartSec=10
TimeoutStartSec=300
TimeoutStopSec=300
EnvironmentFile=/var/www/bot/data/.env
StandardOutput=journal
StandardError=journal
SyslogIdentifier=ave-backend
[Install]
WantedBy=multi-user.target

521
docs/AUDIT_REPORT.md Normal file
View File

@@ -0,0 +1,521 @@
# Randebu Trading Bot - Product & Technical Audit Report
> **Date:** 2026-04-09
> **Phase:** Phase 1 Implementation Complete - Pre-Testing Review
> **Purpose:** Document current state, issues found, and recommendations for next steps
---
## 1. Product Overview
### 1.1 What is Randebu?
Randebu is an AI-powered trading bot platform where users create and manage automated trading strategies through natural language chat—similar to ChatGPT, but specialized for creating trading bots.
### 1.2 Core User Flow
```
User Registration → Create Bot → Chat with AI to Define Strategy
→ Backtest Strategy → Simulate Trading → (Future) Live Trading
```
### 1.3 Phase 1 Scope
| Feature | Status |
|---------|--------|
| BNB Chain only | ✅ Intended (not yet enforced) |
| Backtest engine | ✅ Implemented |
| Simulation engine | ✅ Implemented |
| Natural language strategy parsing | ✅ Implemented |
| User authentication | ✅ Implemented |
| Multi-bot support (max 3) | ✅ Implemented |
| Dummy wallet (database record) | ✅ Implemented |
### 1.4 Tech Stack
| Layer | Technology |
|-------|------------|
| Frontend | Svelte 5 + TypeScript |
| Backend | Python FastAPI |
| AI Agent | CrewAI + MiniMax LLM |
| Database | SQLite |
| Trading Data | AVE Cloud API |
---
## 2. Critical Issues (Must Fix Before Testing)
These issues will cause complete pipeline failure if not addressed.
### 2.1 Database Tables Never Created
**Location:** `src/backend/app/main.py`, `src/backend/run.py`
**Problem:** The application starts but never creates the database tables. There is no:
- Alembic migration setup
- `Base.metadata.create_all()` call on startup
- Database initialization script
**Impact:** First database operation will fail with "table not found" error.
**Current State:**
```python
# core/database.py defines Base, but nothing calls:
# Base.metadata.create_all(engine)
```
**Fix Required:** Add database initialization on application startup.
---
### 2.2 Strategy Config Schema Mismatch
**Location:** Multiple files - see mapping below
**Problem:** The LLM outputs one schema format, but the backtest and simulation engines expect a completely different format. This is a **complete pipeline break** - strategies parsed by AI will never trigger any trades in backtesting.
#### Schema Flow Diagram
```
┌─────────────────────────────────────────────────────────────────────────┐
│ LLM OUTPUT (llm_connector.py) - What AI actually produces │
├─────────────────────────────────────────────────────────────────────────┤
│ { │
│ "type": "price_drop", │
│ "params": { │
│ "token": "PEPE", │
│ "threshold_percent": 5 │
│ } │
│ } │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ BACKEND VALIDATOR (crew.py - StrategyValidator.validate()) │
├─────────────────────────────────────────────────────────────────────────┤
│ # Validator expects params.threshold_percent - THIS WORKS │
│ if "threshold_percent" not in params: │
│ errors.append(f"Condition {i}: missing 'threshold_percent'") │
└─────────────────────────────────────────────────────────────────────────┘
▼ (But engines look for flat fields)
┌─────────────────────────────────────────────────────────────────────────┐
│ BACKTEST ENGINE (services/backtest/engine.py - _check_condition()) │
├─────────────────────────────────────────────────────────────────────────┤
│ # What engine actually looks for: │
│ threshold = condition.get("threshold", 0) # ❌ Returns 0! │
│ token = condition.get("token") # ❌ Wrong path! │
│ timeframe = condition.get("timeframe") # ❌ Not in params! │
│ │
│ # Result: Conditions NEVER trigger because field names don't match │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ SIMULATE ENGINE (services/simulate/engine.py - _check_condition()) │
├─────────────────────────────────────────────────────────────────────────┤
│ # Same issue as backtest engine │
│ threshold = condition.get("threshold", 0) # ❌ Returns 0 │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ FRONTEND TYPES (src/frontend/src/lib/api/types.ts) │
├─────────────────────────────────────────────────────────────────────────┤
│ interface Condition { │
│ type: 'price_drop' | 'price_rise' | 'volume_spike' | 'price_level';│
│ token: string; # Flat - no params wrapper │
│ threshold?: number; # Not threshold_percent! │
│ timeframe?: string; # Exists here │
│ } │
└─────────────────────────────────────────────────────────────────────────┘
```
#### Field Mapping Table
| Component | Token Field | Threshold Field | Timeframe Field |
|-----------|-----------|-----------------|-----------------|
| LLM Output | `params.token` | `params.threshold_percent` | N/A |
| Validator | `params.token` | `params.threshold_percent` | N/A |
| Backtest Engine | `token` | `threshold` | `timeframe` |
| Simulate Engine | `token` | `threshold` | `timeframe` |
| Frontend Types | `token` | `threshold` | `timeframe` |
**Fix Required:** Normalize to ONE consistent schema across the entire pipeline. Recommended: Use the flat structure (token, threshold, timeframe) as it's simpler and already used by engines and frontend.
---
### 2.3 Bot Creation Will Fail
**Location:**
- `src/backend/app/db/schemas.py` (BotCreate)
- `src/frontend/src/lib/api/client.ts` (bots.create)
**Problem:**
| Issue | Details |
|-------|---------|
| Backend requires | `strategy_config: dict` (REQUIRED) |
| Backend requires | `llm_config: dict` (REQUIRED) |
| Frontend sends | Only `name` and optional `description` |
**Impact:** Users cannot create bots through the frontend - API will return validation error.
**Fix Required:** Either:
1. Make `strategy_config` and `llm_config` optional in backend with default values
2. OR update frontend to send default config values
---
### 2.4 Config Endpoints Return Empty Data
**Location:** `src/backend/app/api/config.py`
```python
@router.get("/chains")
def get_chains():
return {"chains": []} # ❌ Always empty
@router.get("/tokens")
def get_tokens():
return {"tokens": []} # ❌ Always empty
```
**Impact:** Frontend cannot populate dropdowns for chain/token selection.
**Fix Required:** Return BSC (BNB Chain) as the only supported chain in Phase 1, and query AVE API for available tokens.
---
## 3. Major Issues
### 3.1 Risk Management Not Implemented
**Location:**
- `src/backend/app/db/models.py` (schema supports it)
- `src/backend/app/services/backtest/engine.py`
- `src/backend/app/services/simulate/engine.py`
**Problem:** The database schema and frontend UI support `risk_management` configuration:
```typescript
interface RiskManagement {
stop_loss_percent?: number;
take_profit_percent?: number;
}
```
However, neither the backtest nor simulation engines actually check or use stop-loss/take-profit logic during trade execution. The config is saved but ignored.
**Fix Required:** Implement actual stop-loss and take-profit checks in both engines.
---
### 3.2 Duplicate AveCloudClient Implementations
**Location:**
- `src/backend/app/services/ave/client.py`
- `src/backend/app/services/backtest/ave_client.py`
**Problem:** Two different AveCloudClient classes with different methods:
| `services/ave/client.py` | `services/backtest/ave_client.py` |
|--------------------------|-----------------------------------|
| `get_tokens()` | ❌ Missing |
| `get_batch_prices()` | ✅ `get_batch_prices()` |
| `get_token_details()` | ❌ Missing |
| `get_klines()` | ✅ `get_klines()` |
| `get_trending_tokens()` | ❌ Missing |
| `get_token_risk()` | ❌ Missing |
| `get_chain_quote()` | ❌ Missing |
| `get_chain_swap()` | ❌ Missing |
| ❌ Missing | `get_token_price()` |
Additionally, the simulate engine imports from the wrong location:
```python
# services/simulate/engine.py
from ..backtest.ave_client import AveCloudClient # ❌ Wrong import
```
**Fix Required:** Consolidate into ONE AveCloudClient class.
---
### 3.3 Silent Error Handling in Simulation
**Location:** `src/backend/app/services/simulate/engine.py`
```python
try:
# ... API calls ...
except Exception as e:
pass # ❌ Silently swallows ALL errors!
```
**Impact:** If AVE API fails or returns bad data, the simulation continues silently with no logging or user feedback.
**Fix Required:** Add proper error logging and user-facing error messages.
---
### 3.4 No Chain Validation for Phase 1
**Problem:** You mentioned limiting to BNB Chain only for Phase 1, but:
- No backend validation enforces this
- Users can specify any chain in backtest/simulate config
- The config endpoints return empty arrays
**Fix Required:** Add chain validation that only allows "bsc" for Phase 1.
---
### 3.5 In-Memory Token Blacklist
**Location:** `src/backend/app/api/auth.py`
```python
TOKEN_BLACKLIST = set() # ❌ In-memory only
```
**Problems:**
- Resets when server restarts
- Doesn't work with multiple workers/processes
- Logout doesn't truly invalidate tokens in production
**Fix Required:** Use Redis or database-backed token blacklist for production.
---
### 3.6 Conversation History Not Passed to Crew
**Location:** `src/backend/app/api/bots.py`
```python
history_for_crew = conversation_history[-10:] # Gets history
crew = get_trading_crew() # ❌ Doesn't pass history!
result = crew.chat(user_message, history_for_crew)
```
The history is fetched but not actually used by the agent - each chat starts fresh.
**Fix Required:** Pass conversation history to the crew agent.
---
### 3.7 No Rate Limiting Applied
**Location:** `src/backend/app/main.py`
```python
app.state.limiter = limiter # Set up but not used on most endpoints
```
The rate limiter is initialized but only applied to the login endpoint. Other endpoints have no protection.
**Fix Required:** Apply rate limiting to sensitive endpoints.
---
### 3.8 CORS Wide Open
**Location:** `src/backend/app/main.py`
```python
allow_origins=["*"] # ❌ Should be restricted to frontend domain
```
**Fix Required:** Limit CORS to the frontend domain in production.
---
### 3.9 No WebSocket for Real-Time Updates
**Problem:** Users must poll the API to see:
- Backtest progress
- Simulation signals (new signals only appear on refresh)
**Impact:** Poor UX during long-running operations.
**Fix Required:** Add WebSocket support for real-time updates (Phase 2 or later).
---
## 4. Minor Issues
### 4.1 Unused Dependencies
**Location:** `src/backend/requirements.txt`
```python
anthropic>=0.18.0 # Included but project uses MiniMax
```
**Fix Required:** Remove unused dependency.
---
### 4.2 Missing .env Example
**Problem:** No `.env.example` file to guide deployment.
**Fix Required:** Create `.env.example` with all required variables documented.
---
### 4.3 No Input Sanitization
User-provided data (bot names, chat messages) isn't sanitized before storage or display.
**Fix Required:** Add input validation and sanitization.
---
### 4.4 Inconsistent Error Responses
Some endpoints return `{"detail": "..."}` (FastAPI default), others return custom error shapes.
**Fix Required:** Standardize error response format.
---
### 4.5 No Integration Tests
No tests that verify the full pipeline (chat → config → backtest).
**Fix Required:** Add integration tests.
---
## 5. Missing Documentation Files
The following should be created:
1. **`.env.example`** - All environment variables with descriptions
2. **`docs/STRATEGY_SCHEMA.md`** - Single source of truth for strategy config schema
3. **`docs/API_SCHEMA.md`** - API contract documentation
4. **`init_db.py`** - Database initialization script
---
## 6. Recommendations Summary
### Priority Matrix
| Priority | Issue | Effort | Impact |
|----------|-------|--------|--------|
| **P0** | Database tables not created | Small | App crashes on startup |
| **P0** | Bot creation fails | Small | Users can't create bots |
| **P0** | Strategy schema mismatch | Medium | Backtesting completely broken |
| **P0** | Config endpoints empty | Small | No chain/token selection |
| **P1** | Risk management not implemented | Medium | No stop-loss/take-profit |
| **P1** | Chain validation missing | Small | Can use non-BSC chains |
| **P1** | Silent error handling | Small | Hard to debug issues |
| **P2** | Duplicate AveCloudClient | Medium | Maintenance burden |
| **P2** | CORS restricted | Small | Security hardening |
| **P2** | Token blacklist (production) | Medium | Security |
| **P2** | Rate limiting | Medium | DoS protection |
| **P3** | WebSocket support | Large | UX improvement |
| **P3** | Integration tests | Medium | Code quality |
---
## 7. AVE Cloud Integration Notes
### Rate Limit Strategy
| Tier | TPS | Recommended Approach |
|------|-----|---------------------|
| Free | 1 | Aggressive caching, batch requests |
| Normal | 5 | Moderate caching |
| Pro | 20 | Minimal caching |
### Caching Recommendations
1. **Token prices:** Cache for 30-60 seconds
2. **Trending tokens:** Cache for 5-10 minutes
3. **Token details:** Cache for 5-10 minutes
4. **Risk assessments:** Cache for 15-30 minutes
### No Testnet Warning
AVE Cloud has **no testnet**. All API calls use real money:
- Use quote/dry-run mode for testing
- Start with minimal amounts ($1-10)
- Contact AVE support about sandbox options
---
## 8. Next Steps
### Immediate (Before Testing)
1. Add database initialization to startup
2. Fix bot creation (frontend or backend)
3. **Normalize strategy schema** - Choose flat structure, update all components
4. Populate config endpoints with BSC + default tokens
5. Add BSC-only chain validation
### Short Term
6. Implement risk management (stop-loss/take-profit)
7. Consolidate AveCloudClient
8. Add proper error handling
9. Create .env.example
10. Add input sanitization
### Medium Term
11. Add WebSocket for real-time updates
12. Implement production token blacklist (Redis)
13. Apply rate limiting
14. Restrict CORS
15. Add integration tests
---
## 9. Files Reference
### Key Backend Files
| File | Purpose |
|------|---------|
| `src/backend/app/main.py` | FastAPI app initialization |
| `src/backend/app/api/bots.py` | Bot CRUD + chat endpoint |
| `src/backend/app/api/backtest.py` | Backtest API |
| `src/backend/app/api/simulate.py` | Simulation API |
| `src/backend/app/api/ave.py` | AVE Cloud proxy endpoints |
| `src/backend/app/api/config.py` | Config endpoints |
| `src/backend/app/db/schemas.py` | Pydantic schemas |
| `src/backend/app/db/models.py` | SQLAlchemy models |
| `src/backend/app/services/ai_agent/crew.py` | CrewAI agents |
| `src/backend/app/services/ai_agent/llm_connector.py` | MiniMax LLM |
| `src/backend/app/services/backtest/engine.py` | Backtest logic |
| `src/backend/app/services/simulate/engine.py` | Simulation logic |
| `src/backend/app/services/ave/client.py` | AVE Cloud client |
### Key Frontend Files
| File | Purpose |
|------|---------|
| `src/frontend/src/lib/api/client.ts` | API client |
| `src/frontend/src/lib/api/types.ts` | TypeScript types |
| `src/frontend/src/routes/bot/[id]/+page.svelte` | Bot chat page |
| `src/frontend/src/routes/bot/[id]/backtest/+page.svelte` | Backtest page |
| `src/frontend/src/routes/bot/[id]/simulate/+page.svelte` | Simulation page |
| `src/frontend/src/lib/components/ChatInterface.svelte` | Chat UI |
| `src/frontend/src/lib/components/StrategyPreview.svelte` | Strategy display |
---
## 10. Audit Complete
This audit was conducted by reviewing:
- All source code in `src/backend/` and `src/frontend/`
- Documentation in `docs/`
- Database models and schemas
- API endpoints and their implementations
The product has a **solid architectural foundation** and addresses a real market need. The core issues are manageable - primarily schema standardization and missing initialization code.
---
*End of Audit Report*

27
docs/ISSUES.md Normal file
View File

@@ -0,0 +1,27 @@
# Open Issues
## Frontend
### Token Address Confirmation Dialog
- **Priority**: High
- **Status**: Open
- **Description**: When user configures a trading strategy via chat and mentions a token (e.g., "buy PEPE"), the AI asks for the token contract address. The frontend should show a confirmation dialog allowing user to:
1. See the token the AI detected (PEPE)
2. Enter/confirm the BSC contract address
3. Save the strategy with the confirmed address
**Related Files**:
- Frontend: `src/frontend/src/routes/bot/[id]/+page.svelte`
- Backend: `src/backend/app/services/ai_agent/conversational.py`
**Acceptance Criteria**:
- [ ] Modal/dialog appears when AI detects a token without address
- [ ] User can enter the contract address (0x...)
- [ ] Strategy is saved only after user confirmation
- [ ] Clear error handling if address is invalid
---
## Backend
*No open backend issues*

279
docs/STRATEGY_SCHEMA.md Normal file
View File

@@ -0,0 +1,279 @@
# Strategy Config Schema
> **Status:** DRAFT - Needs to be normalized with implementation
> **Purpose:** Single source of truth for strategy configuration format
---
## 1. Overview
This document defines the structure of the `strategy_config` JSON object that represents a trading bot's strategy. This config is:
- Generated by the AI from natural language input
- Validated by the backend
- Used by backtest and simulation engines
- Displayed in the frontend
---
## 2. Schema Version
**Current Version:** 1.0
**Status:** Flat structure (NOT nested in `params`)
> **IMPORTANT:** The current implementation has a mismatch where the LLM outputs a nested `params` structure but the engines expect flat fields. This document defines the **TARGET** schema to normalize all components.
---
## 3. Full Schema
```json
{
"version": "1.0",
"conditions": [
{
"type": "price_drop",
"token": "PEPE",
"chain": "bsc",
"threshold": 5,
"timeframe": "1h"
}
],
"actions": [
{
"type": "buy",
"amount_percent": 10,
"token": "PEPE"
}
],
"risk_management": {
"stop_loss_percent": 3,
"take_profit_percent": 10
}
}
```
---
## 4. Field Definitions
### 4.1 Root Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `version` | string | No | Schema version (for future compatibility) |
| `conditions` | array | Yes | List of trigger conditions |
| `actions` | array | Yes | List of actions to execute when conditions are met |
| `risk_management` | object | No | Risk management settings |
### 4.2 Condition Object
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `type` | string | Yes | Condition type (see supported types below) |
| `token` | string | Yes | Token symbol or address (e.g., "PEPE" or "0x123...-bsc") |
| `chain` | string | No | Blockchain chain (default: "bsc") |
| `threshold` | number | For price_drop/rise/volume_spike | Percentage threshold (e.g., 5 = 5%) |
| `price` | number | For price_level | Price level to trigger on |
| `direction` | string | For price_level | "above" or "below" |
| `timeframe` | string | No | Time window for calculation (e.g., "1h", "15m") |
#### Supported Condition Types
| Type | Description | Required Fields |
|------|-------------|-----------------|
| `price_drop` | Triggers when token price drops by threshold % | token, threshold |
| `price_rise` | Triggers when token price rises by threshold % | token, threshold |
| `volume_spike` | Triggers when trading volume increases by threshold % | token, threshold |
| `price_level` | Triggers when price crosses a specific level | token, price, direction |
### 4.3 Action Object
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `type` | string | Yes | Action type (buy, sell, hold, notify) |
| `amount_percent` | number | For buy/sell | Percentage of portfolio to trade |
| `token` | string | No | Token to trade (defaults to condition token) |
#### Supported Action Types
| Type | Description | Required Fields |
|------|-------------|-----------------|
| `buy` | Purchase tokens | amount_percent |
| `sell` | Sell tokens | amount_percent |
| `hold` | Do nothing (log only) | - |
| `notify` | Send notification to user | - |
### 4.4 Risk Management Object
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `stop_loss_percent` | number | No | Exit trade if loss exceeds this % |
| `take_profit_percent` | number | No | Exit trade if profit reaches this % |
---
## 5. Examples
### 5.1 Simple Buy on Price Drop
> "Buy PEPE when it drops 5% in 1 hour"
```json
{
"conditions": [
{
"type": "price_drop",
"token": "PEPE",
"chain": "bsc",
"threshold": 5,
"timeframe": "1h"
}
],
"actions": [
{
"type": "buy",
"amount_percent": 10
}
]
}
```
### 5.2 Buy on Price Rise with Stop Loss
> "Buy when PEPE rises 10%, but stop loss at 3%"
```json
{
"conditions": [
{
"type": "price_rise",
"token": "PEPE",
"threshold": 10,
"timeframe": "4h"
}
],
"actions": [
{
"type": "buy",
"amount_percent": 20
}
],
"risk_management": {
"stop_loss_percent": 3
}
}
```
### 5.3 Sell on Price Level
> "Sell when PEPE reaches $0.0001"
```json
{
"conditions": [
{
"type": "price_level",
"token": "PEPE",
"price": 0.0001,
"direction": "above"
}
],
"actions": [
{
"type": "sell",
"amount_percent": 100
}
]
}
```
### 5.4 Volume Spike Alert
> "Notify me when PEPE volume spikes 50%"
```json
{
"conditions": [
{
"type": "volume_spike",
"token": "PEPE",
"threshold": 50,
"timeframe": "1h"
}
],
"actions": [
{
"type": "notify"
}
]
}
```
---
## 6. Validation Rules
### 6.1 Conditions
- At least one condition is required
- Each condition must have a valid `type`
- Token must be specified
- Threshold must be positive number (for applicable types)
- Price level must be specified for `price_level` type
- Direction must be "above" or "below" for `price_level` type
### 6.2 Actions
- At least one action is required
- Each action must have a valid `type`
- `amount_percent` must be between 0 and 100
### 6.3 Risk Management
- `stop_loss_percent` must be positive
- `take_profit_percent` must be positive
---
## 7. Implementation Status
### Components Using This Schema
| Component | Status | Notes |
|-----------|--------|-------|
| Backend Validator (crew.py) | ❌ Mismatch | Uses nested `params` structure |
| Backtest Engine | ❌ Mismatch | Uses flat structure (correct) |
| Simulate Engine | ❌ Mismatch | Uses flat structure (correct) |
| Frontend Types | ✅ Match | Uses flat structure |
| Frontend StrategyPreview | ✅ Match | Uses flat structure |
### Normalization Required
The LLM output parser should be updated to output flat structure (not nested in `params`) to match what the engines and frontend expect.
---
## 8. Future Extensions
### Potential Condition Types (Phase 2+)
| Type | Description |
|------|-------------|
| `rsi_oversold` | RSI indicator below threshold |
| `rsi_overbought` | RSI indicator above threshold |
| `ma_crossover` | Moving average crossover |
| `bollinger_breakout` | Bollinger Band breakout |
| `news_sentiment` | Based on news sentiment analysis |
### Potential Action Types (Phase 2+)
| Type | Description |
|------|-------------|
| `dca_buy` | Dollar cost averaging buy |
| `trailing_stop` | Trailing stop loss |
| `smart_rebalance` | Portfolio rebalancing |
---
*Document Version: 1.0*
*Last Updated: 2026-04-09*

View File

@@ -1,11 +1,68 @@
# Randebu Trading Bot - Environment Variables Template
# Copy this file to .env and fill in your values
# =============================================================================
# DATABASE
# =============================================================================
# SQLite database path (relative or absolute)
# Example: sqlite:///./data/app.db
DATABASE_URL=sqlite:///./data/app.db DATABASE_URL=sqlite:///./data/app.db
# =============================================================================
# AUTHENTICATION
# =============================================================================
# Secret key for JWT token signing
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY=your-super-secret-key-change-in-production SECRET_KEY=your-super-secret-key-change-in-production
# JWT algorithm (HS256 is recommended)
JWT_ALGORITHM=HS256 JWT_ALGORITHM=HS256
# Token expiration time in minutes (1440 = 24 hours)
ACCESS_TOKEN_EXPIRE_MINUTES=1440 ACCESS_TOKEN_EXPIRE_MINUTES=1440
# =============================================================================
# MINIMAX LLM
# =============================================================================
# MiniMax API key (get from https://platform.minimax.chat/)
MINIMAX_API_KEY=your-minimax-api-key MINIMAX_API_KEY=your-minimax-api-key
MINIMAX_MODEL=MiniMax-Text-01
AVE_API_KEY=your-ave-cloud-api-key # MiniMax model to use
# Common options: MiniMax-Text-01, MiniMax-M2.1
MINIMAX_MODEL=MiniMax-M2.7
# =============================================================================
# AVE CLOUD API
# =============================================================================
# AVE Cloud API key (get from https://cloud.ave.ai/)
AVE_API_KEY=your-ave-api-key
# AVE Cloud plan tier
# Options: free, normal, pro
# Note: Free tier has 1 TPS limit, Pro required for WebSocket
AVE_API_PLAN=free AVE_API_PLAN=free
# =============================================================================
# SERVER CONFIGURATION
# =============================================================================
# Server host (0.0.0.0 for all interfaces)
HOST=0.0.0.0 HOST=0.0.0.0
# Server port
PORT=8000 PORT=8000
# Debug mode (set to false in production)
DEBUG=false DEBUG=false
# =============================================================================
# FRONTEND CONFIGURATION (for reference)
# =============================================================================
# Frontend environment variables (set in frontend .env file):
# VITE_API_URL=https://bot.yourdomain.com/api
# VITE_WS_URL=wss://bot.yourdomain.com/ws

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException, status, Request from fastapi import APIRouter, Depends, HTTPException, status, Request
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import Annotated from typing import Annotated
@@ -14,6 +14,7 @@ from ..core.config import get_settings
from ..core.limiter import limiter from ..core.limiter import limiter
from ..db.schemas import ( from ..db.schemas import (
UserCreate, UserCreate,
LoginRequest,
UserResponse, UserResponse,
Token, Token,
UserSettings, UserSettings,
@@ -58,7 +59,7 @@ def get_current_user(
@router.post( @router.post(
"/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED "/register", response_model=Token, status_code=status.HTTP_201_CREATED
) )
def register(user: UserCreate, db: Session = Depends(get_db)): def register(user: UserCreate, db: Session = Depends(get_db)):
existing_user = db.query(User).filter(User.email == user.email).first() existing_user = db.query(User).filter(User.email == user.email).first()
@@ -75,18 +76,21 @@ def register(user: UserCreate, db: Session = Depends(get_db)):
db.add(db_user) db.add(db_user)
db.commit() db.commit()
db.refresh(db_user) db.refresh(db_user)
return db_user
# Generate and return access token so frontend can proceed immediately
access_token = create_access_token(data={"sub": db_user.id})
return Token(access_token=access_token, token_type="bearer")
@router.post("/login", response_model=Token) @router.post("/login", response_model=Token)
@limiter.limit("5/minute") @limiter.limit("5/minute")
def login( def login(
request: Request, request: Request,
form_data: Annotated[OAuth2PasswordRequestForm, Depends()], login_data: LoginRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
user = db.query(User).filter(User.email == form_data.username).first() user = db.query(User).filter(User.email == login_data.username).first()
if not user or not verify_password(form_data.password, user.password_hash): if not user or not verify_password(login_data.password, user.password_hash):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password", detail="Incorrect email or password",

265
src/backend/app/api/ave.py Normal file
View File

@@ -0,0 +1,265 @@
from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.orm import Session
from typing import Annotated, Optional
import httpx
from .auth import get_current_user
from ..core.database import get_db
from ..core.config import get_settings
from ..db.models import User
from ..services.ave import AveCloudClient, check_tier_access
from ..db.schemas import (
AveBatchPricesRequest,
AveKlinesRequest,
AveChainQuoteRequest,
AveChainSwapRequest,
)
router = APIRouter()
def get_ave_client() -> AveCloudClient:
settings = get_settings()
return AveCloudClient(api_key=settings.AVE_API_KEY, plan=settings.AVE_API_PLAN)
@router.get("/tokens")
async def search_tokens(
query: Optional[str] = None,
chain: Optional[str] = None,
limit: int = 20,
current_user: User = Depends(get_current_user),
):
client = get_ave_client()
try:
tokens = await client.get_tokens(query=query, chain=chain, limit=limit)
return {"tokens": tokens}
except httpx.HTTPStatusError as e:
if e.response.status_code == 429:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Rate limit exceeded. Please try again later.",
)
raise HTTPException(
status_code=e.response.status_code,
detail=f"AVE API error: {e.response.text}",
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to fetch tokens: {str(e)}",
)
@router.post("/tokens/price")
async def get_batch_prices(
request: AveBatchPricesRequest,
current_user: User = Depends(get_current_user),
):
client = get_ave_client()
try:
prices = await client.get_batch_prices(request.token_ids)
return {"prices": prices}
except httpx.HTTPStatusError as e:
if e.response.status_code == 429:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Rate limit exceeded. Please try again later.",
)
raise HTTPException(
status_code=e.response.status_code,
detail=f"AVE API error: {e.response.text}",
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to fetch batch prices: {str(e)}",
)
@router.get("/tokens/{token_id}")
async def get_token_details(
token_id: str,
current_user: User = Depends(get_current_user),
):
client = get_ave_client()
try:
token = await client.get_token_details(token_id)
if token is None:
return {"token": None, "upsell_message": None}
return {"token": token}
except httpx.HTTPStatusError as e:
if e.response.status_code == 429:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Rate limit exceeded. Please try again later.",
)
raise HTTPException(
status_code=e.response.status_code,
detail=f"AVE API error: {e.response.text}",
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to fetch token details: {str(e)}",
)
@router.get("/klines/{token_id}")
async def get_klines(
token_id: str,
interval: str = "1h",
limit: int = 100,
start_time: Optional[int] = None,
end_time: Optional[int] = None,
current_user: User = Depends(get_current_user),
):
client = get_ave_client()
try:
klines = await client.get_klines(
token_id=token_id,
interval=interval,
limit=limit,
start_time=start_time,
end_time=end_time,
)
return {"klines": klines}
except httpx.HTTPStatusError as e:
if e.response.status_code == 429:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Rate limit exceeded. Please try again later.",
)
raise HTTPException(
status_code=e.response.status_code,
detail=f"AVE API error: {e.response.text}",
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to fetch klines: {str(e)}",
)
@router.get("/tokens/trending")
async def get_trending_tokens(
chain: Optional[str] = None,
limit: int = 20,
current_user: User = Depends(get_current_user),
):
client = get_ave_client()
try:
tokens = await client.get_trending_tokens(chain=chain, limit=limit)
return {"tokens": tokens}
except httpx.HTTPStatusError as e:
if e.response.status_code == 429:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Rate limit exceeded. Please try again later.",
)
raise HTTPException(
status_code=e.response.status_code,
detail=f"AVE API error: {e.response.text}",
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to fetch trending tokens: {str(e)}",
)
@router.get("/contracts/{contract_id}")
async def get_token_risk(
contract_id: str,
current_user: User = Depends(get_current_user),
):
client = get_ave_client()
try:
risk = await client.get_token_risk(contract_id)
if risk is None:
return {"risk": None, "upsell_message": None}
return {"risk": risk}
except httpx.HTTPStatusError as e:
if e.response.status_code == 429:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Rate limit exceeded. Please try again later.",
)
raise HTTPException(
status_code=e.response.status_code,
detail=f"AVE API error: {e.response.text}",
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to fetch token risk: {str(e)}",
)
@router.post("/chain/quote")
async def get_chain_quote(
request: AveChainQuoteRequest,
current_user: User = Depends(get_current_user),
):
client = get_ave_client()
try:
quote = await client.get_chain_quote(
chain=request.chain,
from_token=request.from_token,
to_token=request.to_token,
amount=request.amount,
slippage=request.slippage,
)
if quote is None:
return {"quote": None, "upsell_message": None}
return {"quote": quote}
except httpx.HTTPStatusError as e:
if e.response.status_code == 429:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Rate limit exceeded. Please try again later.",
)
raise HTTPException(
status_code=e.response.status_code,
detail=f"AVE API error: {e.response.text}",
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to fetch chain quote: {str(e)}",
)
@router.post("/chain/swap")
async def get_chain_swap(
request: AveChainSwapRequest,
current_user: User = Depends(get_current_user),
):
client = get_ave_client()
try:
swap = await client.get_chain_swap(
chain=request.chain,
from_token=request.from_token,
to_token=request.to_token,
amount=request.amount,
slippage=request.slippage,
wallet_address=request.wallet_address,
)
if swap is None:
return {"swap": None, "upsell_message": None}
return {"swap": swap}
except httpx.HTTPStatusError as e:
if e.response.status_code == 429:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Rate limit exceeded. Please try again later.",
)
raise HTTPException(
status_code=e.response.status_code,
detail=f"AVE API error: {e.response.text}",
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to fetch chain swap: {str(e)}",
)

View File

@@ -1,36 +1,312 @@
from fastapi import APIRouter, Depends, HTTPException, status import uuid
import asyncio
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List from typing import List, Dict, Any, Optional
from concurrent.futures import ThreadPoolExecutor
from .auth import get_current_user
from ..core.database import get_db from ..core.database import get_db
from ..core.config import get_settings
from ..db.schemas import BacktestCreate, BacktestResponse from ..db.schemas import BacktestCreate, BacktestResponse
from ..db.models import Bot, Backtest, Signal, User
router = APIRouter() router = APIRouter()
running_backtests: Dict[str, Any] = {}
executor = ThreadPoolExecutor(max_workers=4)
@router.post("/bots/{bot_id}/backtest", response_model=BacktestResponse)
def start_backtest(bot_id: str, config: BacktestCreate, db: Session = Depends(get_db)): def run_backtest_sync(
raise HTTPException( backtest_id: str, db_url: str, bot_id: str, config: Dict[str, Any]
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Not implemented" ):
import asyncio
import json
from ..services.backtest.engine import BacktestEngine
from ..core.database import SessionLocal
async def _run():
engine = BacktestEngine(config)
engine.run_id = backtest_id
running_backtests[backtest_id] = engine
try:
results = await engine.run()
# Convert datetime objects to ISO strings for JSON serialization
def convert_datetime(obj):
if isinstance(obj, datetime):
return obj.isoformat()
elif isinstance(obj, dict):
return {k: convert_datetime(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [convert_datetime(i) for i in obj]
return obj
results = convert_datetime(results)
db = SessionLocal()
try:
backtest = db.query(Backtest).filter(Backtest.id == backtest_id).first()
if backtest:
backtest.status = engine.status
backtest.ended_at = datetime.utcnow()
backtest.result = results
db.commit()
for signal in engine.signals:
signal_data = convert_datetime(signal)
db_signal = Signal(
id=signal_data["id"],
bot_id=signal_data["bot_id"],
run_id=signal_data["run_id"],
signal_type=signal_data["signal_type"],
token=signal_data["token"],
price=signal_data["price"],
confidence=signal_data.get("confidence"),
reasoning=signal_data.get("reasoning"),
executed=signal_data.get("executed", False),
created_at=signal["created_at"], # Use original datetime, not converted string
) )
db.add(db_signal)
db.commit()
finally:
db.close()
finally:
if backtest_id in running_backtests:
del running_backtests[backtest_id]
asyncio.run(_run())
@router.post(
"/bots/{bot_id}/backtest",
response_model=BacktestResponse,
status_code=status.HTTP_201_CREATED,
)
async def start_backtest(
bot_id: str,
config: BacktestCreate,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
bot = db.query(Bot).filter(Bot.id == bot_id).first()
if not bot:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Bot not found"
)
if bot.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
)
settings = get_settings()
backtest_id = str(uuid.uuid4())
backtest_config = {
"bot_id": bot_id,
"token": config.token,
"chain": config.chain,
"timeframe": config.timeframe,
"start_date": config.start_date,
"end_date": config.end_date,
"strategy_config": bot.strategy_config,
"ave_api_key": settings.AVE_API_KEY,
"ave_api_plan": settings.AVE_API_PLAN,
"initial_balance": 10000.0,
}
backtest = Backtest(
id=backtest_id,
bot_id=bot_id,
started_at=datetime.utcnow(),
status="running",
config={
"token": config.token,
"chain": config.chain,
"timeframe": config.timeframe,
"start_date": config.start_date,
"end_date": config.end_date,
},
)
db.add(backtest)
db.commit()
db.refresh(backtest)
db_url = str(settings.DATABASE_URL)
background_tasks.add_task(
run_backtest_sync, backtest_id, db_url, bot_id, backtest_config
)
return backtest
@router.get("/bots/{bot_id}/backtest/{run_id}", response_model=BacktestResponse) @router.get("/bots/{bot_id}/backtest/{run_id}", response_model=BacktestResponse)
def get_backtest(bot_id: str, run_id: str, db: Session = Depends(get_db)): def get_backtest(
bot_id: str,
run_id: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
bot = db.query(Bot).filter(Bot.id == bot_id).first()
if not bot:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Not implemented" status_code=status.HTTP_404_NOT_FOUND, detail="Bot not found"
)
if bot.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
) )
backtest = (
db.query(Backtest)
.filter(Backtest.id == run_id, Backtest.bot_id == bot_id)
.first()
)
if not backtest:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Backtest not found"
)
# Add progress from running engine if available
if backtest.status == "running" and run_id in running_backtests:
engine = running_backtests[run_id]
backtest.progress = engine.progress
return backtest
@router.get("/bots/{bot_id}/backtest/{run_id}/trades")
def get_backtest_trades(
bot_id: str,
run_id: str,
page: int = 1,
per_page: int = 5,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Get paginated trade history for a specific backtest.
Args:
page: Page number (1-indexed)
per_page: Number of trades per page (default 5, max 20)
"""
bot = db.query(Bot).filter(Bot.id == bot_id).first()
if not bot:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Bot not found"
)
if bot.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
)
backtest = (
db.query(Backtest)
.filter(Backtest.id == run_id, Backtest.bot_id == bot_id)
.first()
)
if not backtest:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Backtest not found"
)
# Get trades from result
result = backtest.result or {}
# Handle case where result might be a JSON string
if isinstance(result, str):
import json
result = json.loads(result)
all_trades = result.get("trades", []) or []
total_trades = len(all_trades)
# Validate pagination params
per_page = min(max(per_page, 1), 20) # Clamp between 1 and 20
page = max(page, 1)
# Calculate pagination
total_pages = max(1, (total_trades + per_page - 1) // per_page) if total_trades > 0 else 1
start_idx = (page - 1) * per_page
end_idx = start_idx + per_page
# Get page of trades (return empty list if start_idx >= total_trades)
paginated_trades = all_trades[start_idx:end_idx] if start_idx < total_trades else []
return {
"backtest_id": run_id,
"trades": paginated_trades,
"total_trades": total_trades,
"page": page,
"per_page": per_page,
"total_pages": total_pages,
"has_next": page < total_pages,
"has_prev": page > 1,
}
@router.get("/bots/{bot_id}/backtests", response_model=List[BacktestResponse]) @router.get("/bots/{bot_id}/backtests", response_model=List[BacktestResponse])
def list_backtests(bot_id: str, db: Session = Depends(get_db)): def list_backtests(
bot_id: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
bot = db.query(Bot).filter(Bot.id == bot_id).first()
if not bot:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Not implemented" status_code=status.HTTP_404_NOT_FOUND, detail="Bot not found"
) )
if bot.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
)
backtests = (
db.query(Backtest)
.filter(Backtest.bot_id == bot_id)
.order_by(Backtest.started_at.desc())
.limit(5)
.all()
)
return backtests
@router.post("/bots/{bot_id}/backtest/{run_id}/stop") @router.post("/bots/{bot_id}/backtest/{run_id}/stop")
def stop_backtest(bot_id: str, run_id: str, db: Session = Depends(get_db)): def stop_backtest(
bot_id: str,
run_id: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
bot = db.query(Bot).filter(Bot.id == bot_id).first()
if not bot:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Not implemented" status_code=status.HTTP_404_NOT_FOUND, detail="Bot not found"
) )
if bot.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
)
backtest = (
db.query(Backtest)
.filter(Backtest.id == run_id, Backtest.bot_id == bot_id)
.first()
)
if not backtest:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Backtest not found"
)
if run_id in running_backtests:
engine = running_backtests[run_id]
engine.running = False # Direct sync access to running flag
backtest.status = "stopped"
backtest.ended_at = datetime.utcnow()
db.commit()
elif backtest.status == "running":
# Engine already finished but status not updated
backtest.status = "stopped"
backtest.ended_at = datetime.utcnow()
db.commit()
return {"status": "stopping", "run_id": run_id}

View File

@@ -1,57 +1,256 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List from typing import List, Annotated
from .auth import get_current_user
from ..core.database import get_db from ..core.database import get_db
from ..db.schemas import BotCreate, BotUpdate, BotResponse from ..core.config import get_settings
from ..db.schemas import (
BotCreate,
BotUpdate,
BotResponse,
BotConversationCreate,
BotConversationResponse,
BotChatRequest,
BotChatResponse,
)
from ..db.models import Bot, BotConversation, User
from ..services.ai_agent.crew import get_trading_crew
from ..services.ai_agent.conversational import get_conversational_agent
router = APIRouter() router = APIRouter()
MAX_BOTS_PER_USER = 3
@router.get("", response_model=List[BotResponse]) @router.get("", response_model=List[BotResponse])
def list_bots(db: Session = Depends(get_db)): def list_bots(
raise HTTPException( current_user: Annotated[User, Depends(get_current_user)],
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Not implemented" db: Session = Depends(get_db),
) ):
bots = db.query(Bot).filter(Bot.user_id == current_user.id).all()
return bots
@router.post("", response_model=BotResponse) @router.post("", response_model=BotResponse, status_code=status.HTTP_201_CREATED)
def create_bot(bot: BotCreate, db: Session = Depends(get_db)): def create_bot(
bot_data: BotCreate,
current_user: Annotated[User, Depends(get_current_user)],
db: Session = Depends(get_db),
):
user_bot_count = db.query(Bot).filter(Bot.user_id == current_user.id).count()
if user_bot_count >= MAX_BOTS_PER_USER:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Not implemented" status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Maximum of {MAX_BOTS_PER_USER} bots per user exceeded",
) )
existing_bot = (
db.query(Bot)
.filter(Bot.user_id == current_user.id, Bot.name == bot_data.name)
.first()
)
if existing_bot:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Bot name must be unique per user",
)
db_bot = Bot(
user_id=current_user.id,
name=bot_data.name,
description=bot_data.description,
strategy_config=bot_data.strategy_config,
llm_config=bot_data.llm_config,
)
db.add(db_bot)
db.commit()
db.refresh(db_bot)
return db_bot
@router.get("/{bot_id}", response_model=BotResponse) @router.get("/{bot_id}", response_model=BotResponse)
def get_bot(bot_id: str, db: Session = Depends(get_db)): def get_bot(
bot_id: str,
current_user: Annotated[User, Depends(get_current_user)],
db: Session = Depends(get_db),
):
bot = db.query(Bot).filter(Bot.id == bot_id).first()
if not bot:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Not implemented" status_code=status.HTTP_404_NOT_FOUND,
detail="Bot not found",
) )
if bot.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to access this bot",
)
return bot
@router.put("/{bot_id}", response_model=BotResponse) @router.put("/{bot_id}", response_model=BotResponse)
def update_bot(bot_id: str, bot: BotUpdate, db: Session = Depends(get_db)): def update_bot(
bot_id: str,
bot_data: BotUpdate,
current_user: Annotated[User, Depends(get_current_user)],
db: Session = Depends(get_db),
):
bot = db.query(Bot).filter(Bot.id == bot_id).first()
if not bot:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Not implemented" status_code=status.HTTP_404_NOT_FOUND,
detail="Bot not found",
)
if bot.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to update this bot",
)
if bot_data.name is not None:
existing_bot = (
db.query(Bot)
.filter(
Bot.user_id == current_user.id,
Bot.name == bot_data.name,
Bot.id != bot_id,
)
.first()
)
if existing_bot:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Bot name must be unique per user",
)
bot.name = bot_data.name
if bot_data.description is not None:
bot.description = bot_data.description
if bot_data.strategy_config is not None:
bot.strategy_config = bot_data.strategy_config
if bot_data.llm_config is not None:
bot.llm_config = bot_data.llm_config
if bot_data.status is not None:
bot.status = bot_data.status
db.commit()
db.refresh(bot)
return bot
@router.delete("/{bot_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_bot(
bot_id: str,
current_user: Annotated[User, Depends(get_current_user)],
db: Session = Depends(get_db),
):
bot = db.query(Bot).filter(Bot.id == bot_id).first()
if not bot:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Bot not found",
)
if bot.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to delete this bot",
)
db.delete(bot)
db.commit()
@router.post("/{bot_id}/chat", response_model=BotChatResponse)
def chat(
bot_id: str,
request: BotChatRequest,
current_user: Annotated[User, Depends(get_current_user)],
db: Session = Depends(get_db),
):
bot = db.query(Bot).filter(Bot.id == bot_id).first()
if not bot:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Bot not found",
)
if bot.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to chat with this bot",
)
conversation_history = (
db.query(BotConversation)
.filter(BotConversation.bot_id == bot_id)
.order_by(BotConversation.created_at)
.all()
)
history_for_agent = [
{"role": conv.role, "content": conv.content}
for conv in conversation_history[-10:]
]
user_message = request.message
# Use ConversationalAgent for natural chat with tool-calling
agent = get_conversational_agent(bot_id=bot_id)
result = agent.chat(user_message, history_for_agent)
assistant_content = result.get("response", "I couldn't process your request.")
# Save conversation
db_conversation = BotConversation(
bot_id=bot_id,
role="user",
content=user_message,
)
db.add(db_conversation)
db_assistant = BotConversation(
bot_id=bot_id,
role="assistant",
content=assistant_content,
)
db.add(db_assistant)
db.commit()
db.refresh(db_assistant)
# If strategy was updated via tool, refresh bot data
if result.get("strategy_updated"):
db.refresh(bot)
return BotChatResponse(
response=assistant_content,
thinking=result.get("thinking"),
strategy_config=bot.strategy_config if result.get("strategy_updated") else None,
success=result.get("success", False),
strategy_needs_confirmation=result.get("strategy_needs_confirmation", False),
strategy_data=result.get("strategy_data") if result.get("strategy_needs_confirmation") else None,
token_search_results=result.get("token_search_results"),
) )
@router.delete("/{bot_id}") @router.get("/{bot_id}/history", response_model=List[BotConversationResponse])
def delete_bot(bot_id: str, db: Session = Depends(get_db)): def get_history(
bot_id: str,
current_user: Annotated[User, Depends(get_current_user)],
db: Session = Depends(get_db),
):
bot = db.query(Bot).filter(Bot.id == bot_id).first()
if not bot:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Not implemented" status_code=status.HTTP_404_NOT_FOUND,
detail="Bot not found",
)
if bot.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to access this bot's history",
) )
conversations = (
@router.post("/{bot_id}/chat") db.query(BotConversation)
def chat(bot_id: str, message: dict, db: Session = Depends(get_db)): .filter(BotConversation.bot_id == bot_id)
raise HTTPException( .order_by(BotConversation.created_at)
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Not implemented" .all()
)
@router.get("/{bot_id}/history")
def get_history(bot_id: str, db: Session = Depends(get_db)):
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Not implemented"
) )
return conversations

View File

@@ -1,13 +1,19 @@
from fastapi import APIRouter from fastapi import APIRouter
from ..core.config import get_settings
from ..services.ave import AveCloudClient
router = APIRouter() router = APIRouter()
@router.get("/chains") @router.get("/chains")
def get_chains(): def get_chains():
return {"chains": []} return {"chains": ["bsc"]}
@router.get("/tokens") @router.get("/tokens")
def get_tokens(): async def get_tokens():
return {"tokens": []} settings = get_settings()
client = AveCloudClient(api_key=settings.AVE_API_KEY, plan=settings.AVE_API_PLAN)
tokens = await client.get_tokens(chain="bsc", limit=20)
return {"tokens": tokens}

View File

@@ -1,38 +1,322 @@
from fastapi import APIRouter, Depends, HTTPException, status import uuid
import asyncio
import logging
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List from typing import List, Dict, Any, Optional
from concurrent.futures import ThreadPoolExecutor
from .auth import get_current_user
from ..core.database import get_db from ..core.database import get_db
from ..core.config import get_settings
from ..db.schemas import SimulationCreate, SimulationResponse from ..db.schemas import SimulationCreate, SimulationResponse
from ..db.models import Bot, Simulation, Signal, User
from ..services.ave.client import AveCloudClient
logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
running_simulations: Dict[str, Any] = {}
executor = ThreadPoolExecutor(max_workers=4)
@router.post("/bots/{bot_id}/simulate", response_model=SimulationResponse)
def start_simulation( def run_simulation_sync(
bot_id: str, config: SimulationCreate, db: Session = Depends(get_db) simulation_id: str, db_url: str, bot_id: str, config: Dict[str, Any]
): ):
raise HTTPException( import asyncio
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Not implemented" import time
from ..services.simulate.engine import SimulateEngine
from ..core.database import SessionLocal
async def _run():
engine = SimulateEngine(config)
engine.run_id = simulation_id
running_simulations[simulation_id] = engine
# Serialize signals for JSON storage (convert datetime to string)
def serialize_signal(s):
created = s.get("created_at")
if hasattr(created, "isoformat"):
created = created.isoformat()
return {
**s,
"created_at": created
}
def save_progress():
"""Save current progress to database."""
db = SessionLocal()
try:
simulation = (
db.query(Simulation).filter(Simulation.id == simulation_id).first()
) )
if simulation:
simulation.status = engine.status
simulation.signals = [serialize_signal(s) for s in engine.signals]
simulation.klines = [
{"time": k.get("time"), "close": k.get("close")}
for k in engine.klines
]
simulation.trade_log = engine.trade_log
# Save portfolio data
simulation.portfolio = {
"initial_balance": engine.config.get("initial_balance", 10000),
"current_balance": engine.current_balance,
"position": engine.position,
"position_token": engine.position_token,
"entry_price": engine.entry_price,
"current_price": engine.last_close,
}
db.commit()
finally:
db.close()
async def run_with_progress_save():
"""Run simulation and save progress periodically."""
last_save_time = time.time()
save_interval = 5 # Save every 5 seconds
while engine.running and engine.status == "running":
await asyncio.sleep(1) # Check every second
current_time = time.time()
if current_time - last_save_time >= save_interval:
save_progress()
last_save_time = current_time
# Final save when done
save_progress()
try:
# Run both simulation and progress saving concurrently
await asyncio.gather(
engine.run(),
run_with_progress_save()
)
finally:
# Save final state
save_progress()
if simulation_id in running_simulations:
del running_simulations[simulation_id]
asyncio.run(_run())
@router.post(
"/bots/{bot_id}/simulate",
response_model=SimulationResponse,
status_code=status.HTTP_201_CREATED,
)
async def start_simulation(
bot_id: str,
config: SimulationCreate,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
bot = db.query(Bot).filter(Bot.id == bot_id).first()
if not bot:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Bot not found"
)
if bot.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
)
# Check if there's already a running simulation for this bot
existing_simulation = (
db.query(Simulation)
.filter(Simulation.bot_id == bot_id, Simulation.status == "running")
.first()
)
if existing_simulation:
# Stop the existing simulation first
if existing_simulation.id in running_simulations:
running_simulations[existing_simulation.id].stop()
del running_simulations[existing_simulation.id]
existing_simulation.status = "stopped"
db.commit()
settings = get_settings()
simulation_id = str(uuid.uuid4())
# Create AVE client for klines fetching
ave_client = AveCloudClient(
api_key=settings.AVE_API_KEY,
plan=settings.AVE_API_PLAN,
)
simulation_config = {
"bot_id": bot_id,
"token": config.token,
"chain": config.chain,
"kline_interval": config.kline_interval,
"auto_execute": False, # Always paper trade
"strategy_config": bot.strategy_config,
"ave_api_key": settings.AVE_API_KEY,
"ave_api_plan": settings.AVE_API_PLAN,
}
simulation = Simulation(
id=simulation_id,
bot_id=bot_id,
started_at=datetime.utcnow(),
status="running",
config={
"token": config.token,
"chain": config.chain,
"kline_interval": config.kline_interval,
},
signals=[],
klines=[],
)
db.add(simulation)
db.commit()
db.refresh(simulation)
# Fetch klines SYNCHRONOUSLY so user can see chart immediately
try:
token_id = f"{config.token}-{config.chain}"
# Calculate time range (last 1 hour)
import time
end_time = int(time.time() * 1000)
start_time = end_time - (60 * 60 * 1000) # 1 hour ago
klines_data = await ave_client.get_klines(
token_id,
interval=config.kline_interval,
start_time=start_time,
end_time=end_time,
limit=500
)
klines_for_chart = [
{"time": k.get("time"), "close": k.get("close")}
for k in sorted(klines_data, key=lambda x: x.get("time", 0))
]
# Update simulation with klines
simulation.klines = klines_for_chart
db.commit()
db.refresh(simulation)
logger.info(f"Fetched {len(klines_for_chart)} klines for simulation {simulation_id}")
except Exception as e:
logger.error(f"Failed to fetch klines: {e}")
# Run simulation in background for signal processing
background_tasks.add_task(
run_simulation_sync, simulation_id, str(settings.DATABASE_URL), bot_id, simulation_config
)
return simulation
@router.get("/bots/{bot_id}/simulate/{run_id}", response_model=SimulationResponse) @router.get("/bots/{bot_id}/simulate/{run_id}", response_model=SimulationResponse)
def get_simulation(bot_id: str, run_id: str, db: Session = Depends(get_db)): def get_simulation(
bot_id: str,
run_id: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
bot = db.query(Bot).filter(Bot.id == bot_id).first()
if not bot:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Not implemented" status_code=status.HTTP_404_NOT_FOUND, detail="Bot not found"
) )
if bot.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
)
simulation = (
db.query(Simulation)
.filter(Simulation.id == run_id, Simulation.bot_id == bot_id)
.first()
)
if not simulation:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Simulation not found"
)
if run_id in running_simulations:
engine = running_simulations[run_id]
simulation.signals = engine.get_signals()
return simulation
@router.get("/bots/{bot_id}/simulations", response_model=List[SimulationResponse]) @router.get("/bots/{bot_id}/simulations", response_model=List[SimulationResponse])
def list_simulations(bot_id: str, db: Session = Depends(get_db)): def list_simulations(
bot_id: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
bot = db.query(Bot).filter(Bot.id == bot_id).first()
if not bot:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Not implemented" status_code=status.HTTP_404_NOT_FOUND, detail="Bot not found"
) )
if bot.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
)
simulations = (
db.query(Simulation)
.filter(Simulation.bot_id == bot_id)
.order_by(Simulation.started_at.desc())
.all()
)
for sim in simulations:
if sim.id in running_simulations:
engine = running_simulations[sim.id]
sim.signals = engine.get_signals()
# Include klines from running engine for chart display
if hasattr(engine, 'klines'):
sim.klines = [{"time": k.get("time"), "close": k.get("close")} for k in engine.klines]
return simulations
@router.post("/bots/{bot_id}/simulate/{run_id}/stop") @router.post("/bots/{bot_id}/simulate/{run_id}/stop")
def stop_simulation(bot_id: str, run_id: str, db: Session = Depends(get_db)): def stop_simulation(
bot_id: str,
run_id: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
bot = db.query(Bot).filter(Bot.id == bot_id).first()
if not bot:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Not implemented" status_code=status.HTTP_404_NOT_FOUND, detail="Bot not found"
) )
if bot.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
)
simulation = (
db.query(Simulation)
.filter(Simulation.id == run_id, Simulation.bot_id == bot_id)
.first()
)
if not simulation:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Simulation not found"
)
# Always update status to stopped, even if engine is not in memory
simulation.status = "stopped"
# Try to stop the engine if it's still in memory
if run_id in running_simulations:
engine = running_simulations[run_id]
engine.stop()
del running_simulations[run_id]
db.commit()
return {"status": "stopped", "run_id": run_id}

1
src/backend/app/ave Symbolic link
View File

@@ -0,0 +1 @@
../../ave-cloud-skill/scripts/ave

View File

@@ -25,6 +25,7 @@ class User(Base):
id = Column(String, primary_key=True, default=generate_uuid) id = Column(String, primary_key=True, default=generate_uuid)
email = Column(String, unique=True, nullable=False) email = Column(String, unique=True, nullable=False)
password_hash = Column(String, nullable=False) password_hash = Column(String, nullable=False)
tier = Column(String, default="free")
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -92,6 +93,9 @@ class Simulation(Base):
status = Column(String, nullable=False) status = Column(String, nullable=False)
config = Column(JSON, nullable=False) config = Column(JSON, nullable=False)
signals = Column(JSON) signals = Column(JSON)
klines = Column(JSON) # Price data for chart display
trade_log = Column(JSON) # Trade activity log
portfolio = Column(JSON) # Portfolio data
bot = relationship("Bot", back_populates="simulations") bot = relationship("Bot", back_populates="simulations")

View File

@@ -1,5 +1,5 @@
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr, field_validator
from typing import Optional, List, Any from typing import Optional, List, Any, Dict
from datetime import datetime from datetime import datetime
@@ -8,6 +8,11 @@ class UserCreate(BaseModel):
password: str password: str
class LoginRequest(BaseModel):
username: EmailStr
password: str
class UserResponse(BaseModel): class UserResponse(BaseModel):
id: str id: str
email: str email: str
@@ -35,8 +40,8 @@ class UserSettingsUpdate(BaseModel):
class BotCreate(BaseModel): class BotCreate(BaseModel):
name: str name: str
description: Optional[str] = None description: Optional[str] = None
strategy_config: dict strategy_config: Optional[dict] = {}
llm_config: dict llm_config: Optional[dict] = {}
class BotUpdate(BaseModel): class BotUpdate(BaseModel):
@@ -64,11 +69,19 @@ class BotResponse(BaseModel):
class BacktestCreate(BaseModel): class BacktestCreate(BaseModel):
token: str token: str
token_name: Optional[str] = None
chain: str chain: str
timeframe: str timeframe: str
start_date: str start_date: str
end_date: str end_date: str
@field_validator("chain")
@classmethod
def chain_must_be_bsc(cls, v: str) -> str:
if v != "bsc":
raise ValueError("Phase 1 only supports BSC (bnb chain)")
return v
class BacktestResponse(BaseModel): class BacktestResponse(BaseModel):
id: str id: str
@@ -78,6 +91,7 @@ class BacktestResponse(BaseModel):
status: str status: str
config: dict config: dict
result: Optional[dict] result: Optional[dict]
progress: Optional[int] = None
class Config: class Config:
from_attributes = True from_attributes = True
@@ -86,8 +100,14 @@ class BacktestResponse(BaseModel):
class SimulationCreate(BaseModel): class SimulationCreate(BaseModel):
token: str token: str
chain: str chain: str
duration_seconds: int = 3600 kline_interval: str = "1m"
auto_execute: bool = False
@field_validator("chain")
@classmethod
def chain_must_be_bsc(cls, v: str) -> str:
if v != "bsc":
raise ValueError("Phase 1 only supports BSC (bnb chain)")
return v
class SimulationResponse(BaseModel): class SimulationResponse(BaseModel):
@@ -97,6 +117,12 @@ class SimulationResponse(BaseModel):
status: str status: str
config: dict config: dict
signals: Optional[List[dict]] signals: Optional[List[dict]]
klines: Optional[List[dict]] = None # Price data for chart
trade_log: Optional[List[dict]] = None # Trade activity log
portfolio: Optional[dict] = None # Portfolio data
current_candle_index: Optional[int] = None # Progress: current candle
total_candles: Optional[int] = None # Progress: total candles
candles_processed: Optional[int] = None # Progress: candles processed
class Config: class Config:
from_attributes = True from_attributes = True
@@ -118,6 +144,21 @@ class BotConversationResponse(BaseModel):
from_attributes = True from_attributes = True
class BotChatRequest(BaseModel):
message: str
strategy_config: Optional[bool] = False
class BotChatResponse(BaseModel):
response: str
thinking: Optional[str] = None
strategy_config: Optional[dict] = None
success: bool = False
strategy_needs_confirmation: Optional[bool] = False
strategy_data: Optional[dict] = None
token_search_results: Optional[List[dict]] = None
class SignalResponse(BaseModel): class SignalResponse(BaseModel):
id: str id: str
bot_id: str bot_id: str
@@ -132,3 +173,72 @@ class SignalResponse(BaseModel):
class Config: class Config:
from_attributes = True from_attributes = True
class AveTokenSearchResponse(BaseModel):
tokens: List[dict]
upsell_message: Optional[str] = None
class AveBatchPricesRequest(BaseModel):
token_ids: List[str]
class AveBatchPricesResponse(BaseModel):
prices: Dict[str, dict]
upsell_message: Optional[str] = None
class AveTokenDetailsResponse(BaseModel):
token: Optional[dict] = None
upsell_message: Optional[str] = None
class AveKlinesRequest(BaseModel):
token_id: str
interval: str = "1h"
limit: int = 100
start_time: Optional[int] = None
end_time: Optional[int] = None
class AveKlinesResponse(BaseModel):
klines: List[dict]
upsell_message: Optional[str] = None
class AveTrendingTokensResponse(BaseModel):
tokens: List[dict]
upsell_message: Optional[str] = None
class AveTokenRiskResponse(BaseModel):
risk: Optional[dict] = None
upsell_message: Optional[str] = None
class AveChainQuoteRequest(BaseModel):
chain: str
from_token: str
to_token: str
amount: str
slippage: float = 0.5
class AveChainQuoteResponse(BaseModel):
quote: Optional[dict] = None
upsell_message: Optional[str] = None
class AveChainSwapRequest(BaseModel):
chain: str
from_token: str
to_token: str
amount: str
slippage: float = 0.5
wallet_address: Optional[str] = None
class AveChainSwapResponse(BaseModel):
swap: Optional[dict] = None
upsell_message: Optional[str] = None

View File

@@ -1,14 +1,35 @@
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from slowapi import Limiter from slowapi import Limiter
from slowapi.util import get_remote_address from slowapi.util import get_remote_address
from .api import auth, bots, backtest, simulate, config from .api import auth, bots, backtest, simulate, config, ave
from .core.limiter import limiter from .core.limiter import limiter
from .core.database import engine, Base
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Initialize database on startup."""
# Import all models to ensure they're registered
from .db.models import User, Bot, BotConversation, Backtest, Simulation, Signal
# Create tables if they don't exist
Base.metadata.create_all(bind=engine)
logger.info("Database initialized successfully")
yield
# Cleanup on shutdown if needed
app = FastAPI( app = FastAPI(
title="Randebu Trading Bot API", title="Randebu Trading Bot API",
description="AI-powered trading bot platform API", description="AI-powered trading bot platform API",
version="0.1.0", version="0.1.0",
lifespan=lifespan,
) )
app.state.limiter = limiter app.state.limiter = limiter
@@ -26,6 +47,7 @@ app.include_router(bots.router, prefix="/api/bots", tags=["bots"])
app.include_router(backtest.router, prefix="/api", tags=["backtest"]) app.include_router(backtest.router, prefix="/api", tags=["backtest"])
app.include_router(simulate.router, prefix="/api", tags=["simulate"]) app.include_router(simulate.router, prefix="/api", tags=["simulate"])
app.include_router(config.router, prefix="/api/config", tags=["config"]) app.include_router(config.router, prefix="/api/config", tags=["config"])
app.include_router(ave.router, prefix="/api/ave", tags=["ave"])
@app.get("/") @app.get("/")

View File

@@ -1,4 +1,4 @@
from .crew import CrewAgent from .crew import TradingCrew, get_trading_crew
from .llm_connector import LLMConnector from .llm_connector import MiniMaxLLM, MiniMaxConnector
__all__ = ["CrewAgent", "LLMConnector"] __all__ = ["TradingCrew", "get_trading_crew", "MiniMaxLLM", "MiniMaxConnector"]

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,241 @@
from typing import List, Optional from typing import List, Optional, Dict, Any
from crewai import Agent, Task, Crew, LLM
from .llm_connector import MiniMaxConnector
from ...core.config import get_settings
class CrewAgent: class StrategyValidator:
def __init__(self, role: str, goal: str, backstory: str): SUPPORTED_CONDITIONS = ["price_drop", "price_rise", "volume_spike", "price_level"]
self.role = role SUPPORTED_ACTIONS = ["buy", "sell", "notify"]
self.goal = goal
self.backstory = backstory
def execute_task(self, task: str) -> str: def validate(self, strategy_config: dict) -> tuple[bool, list[str]]:
raise NotImplementedError("CrewAI agent not yet implemented") errors = []
if "conditions" not in strategy_config:
errors.append("Missing 'conditions' in strategy config")
return False, errors
if not isinstance(strategy_config["conditions"], list):
errors.append("'conditions' must be a list")
return False, errors
if len(strategy_config["conditions"]) == 0:
errors.append("At least one condition is required")
return False, errors
for i, condition in enumerate(strategy_config["conditions"]):
if "type" not in condition:
errors.append(f"Condition {i}: missing 'type'")
continue
cond_type = condition.get("type")
if cond_type not in self.SUPPORTED_CONDITIONS:
errors.append(f"Condition {i}: unsupported type '{cond_type}'")
continue
if cond_type in ["price_drop", "price_rise", "volume_spike"]:
if "token" not in condition:
errors.append(f"Condition {i}: missing 'token'")
if "threshold" not in condition:
errors.append(f"Condition {i}: missing 'threshold'")
elif not isinstance(condition["threshold"], (int, float)):
errors.append(f"Condition {i}: 'threshold' must be a number")
elif condition["threshold"] <= 0:
errors.append(f"Condition {i}: 'threshold' must be positive")
elif cond_type == "price_level":
if "token" not in condition:
errors.append(f"Condition {i}: missing 'token'")
if "price" not in condition:
errors.append(f"Condition {i}: missing 'price'")
if "direction" not in condition:
errors.append(f"Condition {i}: missing 'direction'")
elif condition["direction"] not in ["above", "below"]:
errors.append(
f"Condition {i}: direction must be 'above' or 'below'"
)
if "actions" in strategy_config:
if not isinstance(strategy_config["actions"], list):
errors.append("'actions' must be a list")
else:
for i, action in enumerate(strategy_config["actions"]):
if "type" not in action:
errors.append(f"Action {i}: missing 'type'")
elif action["type"] not in self.SUPPORTED_ACTIONS:
errors.append(
f"Action {i}: unsupported type '{action['type']}'"
)
return len(errors) == 0, errors
def get_trading_crew(): class StrategyExplainer:
raise NotImplementedError("Trading crew not yet implemented") def explain(self, strategy_config: dict) -> str:
explanations = []
if "conditions" in strategy_config:
cond_list = strategy_config["conditions"]
if cond_list:
explanations.append("This strategy will trigger when:")
for cond in cond_list:
cond_type = cond.get("type")
token = cond.get("token", "the token")
if cond_type == "price_drop":
pct = cond.get("threshold", 0)
explanations.append(f" - {token} price drops by {pct}%")
elif cond_type == "price_rise":
pct = cond.get("threshold", 0)
explanations.append(f" - {token} price rises by {pct}%")
elif cond_type == "volume_spike":
pct = cond.get("threshold", 0)
explanations.append(
f" - {token} trading volume increases by {pct}%"
)
elif cond_type == "price_level":
price = cond.get("price", 0)
direction = cond.get("direction", "unknown")
explanations.append(
f" - {token} price crosses {direction} ${price}"
)
if "actions" in strategy_config:
actions = strategy_config.get("actions", [])
if actions:
explanations.append("\nWhen triggered, the strategy will:")
for action in actions:
action_type = action.get("type")
if action_type == "buy":
explanations.append(" - Buy the token")
elif action_type == "sell":
explanations.append(" - Sell the token")
elif action_type == "notify":
explanations.append(" - Send a notification")
if not explanations:
explanations.append("Strategy configuration is empty or invalid.")
return "\n".join(explanations)
def create_trading_designer_agent(
api_key: str, model: str = "MiniMax-M2.7"
) -> Agent:
connector = MiniMaxConnector(api_key=api_key, model=model)
system_prompt = """You are a Trading Strategy Designer AI. Your role is to parse user requests
for trading strategies into structured JSON configuration.
Supported conditions (MVP):
- price_drop: Triggers when a token's price drops by a specified percentage
- price_rise: Triggers when a token's price rises by a specified percentage
- volume_spike: Triggers when trading volume increases by a specified percentage
- price_level: Triggers when price crosses above or below a specified level
Always ask clarifying questions if the user's request is ambiguous.
Output strategy_config in valid JSON format only when you have all required information.
"""
return Agent(
role="Trading Strategy Designer",
goal="Convert natural language trading requests into precise strategy configurations",
backstory=system_prompt,
llm=LLM(model=model, api_key=api_key, api_base="https://api.minimax.io/v1"),
verbose=True,
)
def create_strategy_validator_agent(
api_key: str, model: str = "MiniMax-M2.7"
) -> Agent:
return Agent(
role="Strategy Validator",
goal="Validate trading strategy configurations for feasibility and identify potential issues",
backstory="""You are a meticulous strategy validator with expertise in trading systems.
You check that all required parameters are present, values are reasonable, and the
strategy makes logical sense. You never approve strategies with missing or invalid data.""",
llm=LLM(model=model, api_key=api_key, api_base="https://api.minimax.io/v1"),
verbose=True,
)
def create_strategy_explainer_agent(
api_key: str, model: str = "MiniMax-M2.7"
) -> Agent:
return Agent(
role="Strategy Explainer",
goal="Generate clear, user-friendly explanations of trading strategies",
backstory="""You are a patient trading strategy explainer. You translate complex
strategy configurations into easy-to-understand language. You help users understand
exactly what their strategies will do when triggered.""",
llm=LLM(model=model, api_key=api_key, api_base="https://api.minimax.io/v1"),
verbose=True,
)
class TradingCrew:
def __init__(self, api_key: str, model: str = "MiniMax-M2.7"):
self.api_key = api_key
self.model = model
self.validator = StrategyValidator()
self.explainer = StrategyExplainer()
self.connector = MiniMaxConnector(api_key=api_key, model=model)
def parse_strategy(
self, user_message: str, conversation_history: list[dict] = None
) -> dict:
strategy_config = self.connector.parse_strategy(
user_message, conversation_history
)
if "error" in strategy_config:
return strategy_config
is_valid, errors = self.validator.validate(strategy_config)
if not is_valid:
return {
"error": "Strategy validation failed",
"validation_errors": errors,
"partial_config": strategy_config,
}
return strategy_config
def explain_strategy(self, strategy_config: dict) -> str:
return self.explainer.explain(strategy_config)
def chat(self, user_message: str, conversation_history: list[dict] = None) -> dict:
strategy_config = self.parse_strategy(user_message, conversation_history)
if "error" in strategy_config:
explanation = f"I had trouble understanding your strategy: {strategy_config.get('error', 'Unknown error')}"
if "validation_errors" in strategy_config:
explanation += "\n\nValidation issues:"
for err in strategy_config["validation_errors"]:
explanation += f"\n - {err}"
return {
"response": explanation,
"strategy_config": strategy_config.get("partial_config"),
"success": False,
}
explanation = self.explain_strategy(strategy_config)
return {
"response": f"I've configured your strategy:\n\n{explanation}",
"strategy_config": strategy_config,
"success": True,
}
def get_trading_crew(
api_key: Optional[str] = None, model: Optional[str] = None
) -> TradingCrew:
if api_key is None:
settings = get_settings()
api_key = settings.MINIMAX_API_KEY
if model is None:
settings = get_settings()
model = settings.MINIMAX_MODEL
return TradingCrew(api_key=api_key, model=model)

View File

@@ -1,13 +1,105 @@
from typing import Optional from typing import Optional, List, Dict, Any
import httpx
class LLMConnector: class MiniMaxLLM:
def __init__(self, api_key: str, model: str = "MiniMax-Text-01"): def __init__(self, api_key: str, model: str = "MiniMax-M2.7", **kwargs):
self.api_key = api_key
self.model = model
self.base_url = "https://api.minimax.io/v1"
def _call(self, messages: List[Dict[str, str]], **kwargs) -> str:
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
payload = {
"model": self.model,
"messages": messages,
"temperature": kwargs.get("temperature", 0.7),
"max_tokens": kwargs.get("max_tokens", 2048),
}
with httpx.Client(timeout=60.0) as client:
response = client.post(
f"{self.base_url}/text/chatcompletion_v2",
headers=headers,
json=payload,
)
response.raise_for_status()
return response.json()["choices"][0]["message"]["content"]
def call(self, messages: List[Dict[str, str]], **kwargs) -> str:
return self._call(messages, **kwargs)
class MiniMaxConnector:
def __init__(self, api_key: str, model: str = "MiniMax-M2.7"):
self.api_key = api_key self.api_key = api_key
self.model = model self.model = model
def chat(self, messages: list[dict], **kwargs): def chat(self, messages: list[dict], **kwargs) -> str:
raise NotImplementedError("LLM integration not yet implemented") formatted_messages = []
for msg in messages:
if isinstance(msg, dict):
formatted_messages.append(
{
"role": msg.get("role", "user"),
"content": msg.get("content", str(msg)),
}
)
else:
formatted_messages.append({"role": "user", "content": str(msg)})
def parse_strategy(self, user_message: str) -> dict: llm = MiniMaxLLM(api_key=self.api_key, model=self.model)
raise NotImplementedError("Strategy parsing not yet implemented") return llm.call(formatted_messages, **kwargs)
def parse_strategy(
self, user_message: str, conversation_history: list[dict] = None
) -> dict:
system_prompt = """You are a trading strategy designer. Parse the user's natural language request into a JSON strategy_config object.
Supported conditions (MVP):
- price_drop: Token price drops by X% (requires: token, threshold)
- price_rise: Token price rises by X% (requires: token, threshold)
- volume_spike: Trading volume increases X% (requires: token, threshold)
- price_level: Price crosses above/below X (requires: token, price, direction)
Output ONLY valid JSON with this schema:
{
"conditions": [
{
"type": "price_drop|price_rise|volume_spike|price_level",
"token": "TOKEN_SYMBOL",
"chain": "bsc",
"threshold": number, // for price_drop, price_rise, volume_spike
"price": number, // for price_level
"direction": "above|below", // for price_level
"timeframe": "1h"
}
],
"actions": [
{
"type": "buy|sell|notify"
}
]
}
If the user wants a condition not in the supported list, ask for clarification.
"""
messages = [{"role": "system", "content": system_prompt}]
if conversation_history:
for msg in conversation_history:
messages.append(
{"role": msg.get("role", "user"), "content": msg.get("content", "")}
)
messages.append({"role": "user", "content": user_message})
response = self.chat(messages)
try:
import json
result = json.loads(response)
return result
except json.JSONDecodeError:
return {"error": "Failed to parse strategy", "raw_response": response}

View File

@@ -0,0 +1,3 @@
from .client import AveCloudClient, check_tier_access
__all__ = ["AveCloudClient", "check_tier_access"]

View File

@@ -0,0 +1,243 @@
import httpx
from typing import List, Dict, Any, Optional
from datetime import datetime
class AveCloudClient:
DATA_API_URL = "https://prod.ave-api.com"
TRADING_API_URL = "https://bot-api.ave.ai"
def __init__(self, api_key: str, plan: str = "free"):
self.api_key = api_key
self.plan = plan
def _data_headers(self) -> Dict[str, str]:
return {"X-API-KEY": self.api_key}
def _trading_headers(self) -> Dict[str, str]:
return {"X-API-KEY": self.api_key, "Content-Type": "application/json"}
async def get_tokens(
self,
query: Optional[str] = None,
chain: Optional[str] = None,
limit: int = 20,
) -> List[Dict[str, Any]]:
# Use trending endpoint which supports chain filter
url = f"{self.DATA_API_URL}/v2/tokens/trending"
params = {"limit": min(limit, 100)} # API returns max 100
if chain:
params["chain"] = chain
async with httpx.AsyncClient() as client:
response = await client.get(
url, headers=self._data_headers(), params=params, timeout=30.0
)
response.raise_for_status()
data = response.json()
if data.get("status") == 1: # 1 = SUCCESS
tokens = data.get("data", {}).get("tokens", [])
# Filter by query if provided
if query:
query_lower = query.lower()
tokens = [
t for t in tokens
if query_lower in t.get("symbol", "").lower()
or query_lower in t.get("name", "").lower()
]
return tokens[:limit]
return []
raise Exception(f"Failed to fetch tokens: {data}")
async def get_batch_prices(self, token_ids: List[str]) -> Dict[str, Dict[str, Any]]:
url = f"{self.DATA_API_URL}/v2/tokens/price"
async with httpx.AsyncClient() as client:
response = await client.post(
url,
headers=self._data_headers(),
json={"token_ids": token_ids},
timeout=30.0,
)
response.raise_for_status()
data = response.json()
if data.get("status") == 200:
return data.get("data", {})
return {}
async def get_token_details(self, token_id: str) -> Optional[Dict[str, Any]]:
url = f"{self.DATA_API_URL}/v2/tokens/{token_id}"
async with httpx.AsyncClient() as client:
response = await client.get(url, headers=self._data_headers(), timeout=30.0)
response.raise_for_status()
data = response.json()
if data.get("status") == 200:
return data.get("data")
return None
async def get_klines(
self,
token_id: str,
interval: str = "1h",
limit: int = 100,
start_time: Optional[int] = None,
end_time: Optional[int] = None,
) -> List[Dict[str, Any]]:
# Token ID must be in format "{contract_address}-bsc" for the AVE API
if not token_id.endswith("-bsc") and token_id.startswith("0x"):
token_id = f"{token_id}-bsc"
url = f"{self.DATA_API_URL}/v2/klines/token/{token_id}"
params = {"interval": interval, "limit": limit}
if start_time:
params["start_time"] = start_time
if end_time:
params["end_time"] = end_time
async with httpx.AsyncClient() as client:
response = await client.get(
url, headers=self._data_headers(), params=params, timeout=30.0
)
response.raise_for_status()
data = response.json()
# AVE API returns status: 1 for success, not 200
if data.get("status") == 1:
return data.get("data", {}).get("points", [])
raise Exception(f"Failed to fetch klines: {data}")
async def get_token_price(self, token_id: str) -> Optional[Dict[str, Any]]:
url = f"{self.DATA_API_URL}/v2/tokens/price"
async with httpx.AsyncClient() as client:
response = await client.post(
url,
headers=self._data_headers(),
json={"token_ids": [token_id]},
timeout=30.0,
)
response.raise_for_status()
data = response.json()
if data.get("status") == 1:
prices = data.get("data", {})
return prices.get(token_id)
return None
async def get_trending_tokens(
self, chain: Optional[str] = None, limit: int = 20
) -> List[Dict[str, Any]]:
url = f"{self.DATA_API_URL}/v2/tokens/trending"
params = {"limit": limit}
if chain:
params["chain"] = chain
async with httpx.AsyncClient() as client:
response = await client.get(
url, headers=self._data_headers(), params=params, timeout=30.0
)
response.raise_for_status()
data = response.json()
if data.get("status") == 200:
return data.get("data", [])
raise Exception(f"Failed to fetch trending tokens: {data}")
async def get_token_risk(self, contract_id: str) -> Optional[Dict[str, Any]]:
url = f"{self.DATA_API_URL}/v2/contracts/{contract_id}"
async with httpx.AsyncClient() as client:
response = await client.get(url, headers=self._data_headers(), timeout=30.0)
response.raise_for_status()
data = response.json()
if data.get("status") == 200:
return data.get("data")
return None
async def get_chain_quote(
self,
chain: str,
from_token: str,
to_token: str,
amount: str,
slippage: float = 0.5,
) -> Optional[Dict[str, Any]]:
url = f"{self.TRADING_API_URL}/v1/chain/quote"
payload = {
"chain": chain,
"from_token": from_token,
"to_token": to_token,
"amount": amount,
"slippage": slippage,
}
async with httpx.AsyncClient() as client:
response = await client.post(
url, headers=self._trading_headers(), json=payload, timeout=30.0
)
response.raise_for_status()
data = response.json()
if data.get("status") == 200:
return data.get("data")
return None
async def get_chain_swap(
self,
chain: str,
from_token: str,
to_token: str,
amount: str,
slippage: float = 0.5,
wallet_address: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
url = f"{self.TRADING_API_URL}/v1/chain/swap"
payload = {
"chain": chain,
"from_token": from_token,
"to_token": to_token,
"amount": amount,
"slippage": slippage,
}
if wallet_address:
payload["wallet_address"] = wallet_address
async with httpx.AsyncClient() as client:
response = await client.post(
url, headers=self._trading_headers(), json=payload, timeout=60.0
)
response.raise_for_status()
data = response.json()
if data.get("status") == 200:
return data.get("data")
return None
def check_tier_access(user_tier: str, feature: str) -> tuple[bool, Optional[str]]:
tier_access = {
"free": {
"data_rest": True,
"websocket": False,
"chain_wallet": True,
"proxy_wallet": False,
},
"normal": {
"data_rest": True,
"websocket": False,
"chain_wallet": True,
"proxy_wallet": True,
},
"pro": {
"data_rest": True,
"websocket": True,
"chain_wallet": True,
"proxy_wallet": True,
},
}
if user_tier not in tier_access:
user_tier = "free"
access = tier_access[user_tier]
if access.get(feature):
return True, None
upsell_messages = {
"websocket": "Upgrade to Pro plan to access WebSocket streaming data. Visit your account settings.",
"proxy_wallet": "Upgrade to Normal or Pro plan to access Proxy Wallet functionality. Visit your account settings.",
}
return False, upsell_messages.get(
feature, "Upgrade your plan to access this feature."
)

View File

@@ -1,15 +1,475 @@
from typing import Optional, Dict, Any import uuid
import asyncio
from datetime import datetime
from typing import Dict, Any, List, Optional
from ..ave.client import AveCloudClient
class BacktestEngine: class BacktestEngine:
def __init__(self, config: Dict[str, Any]): def __init__(self, config: Dict[str, Any]):
self.config = config self.config = config
self.run_id = str(uuid.uuid4())
self.status = "pending"
self.results: Optional[Dict[str, Any]] = None
self.signals: List[Dict[str, Any]] = []
self.ave_client = AveCloudClient(
api_key=config.get("ave_api_key", ""),
plan=config.get("ave_api_plan", "free"),
)
self.bot_id = config.get("bot_id")
self.strategy_config = config.get("strategy_config", {})
self.conditions = self.strategy_config.get("conditions", [])
self.actions = self.strategy_config.get("actions", [])
self.risk_management = self.strategy_config.get("risk_management", {})
self.stop_loss_percent = self.risk_management.get("stop_loss_percent")
self.take_profit_percent = self.risk_management.get("take_profit_percent")
self.initial_balance = config.get("initial_balance", 10000.0)
self.current_balance = self.initial_balance
self.position = 0.0
self.position_token = ""
self.entry_price: Optional[float] = None
self.cost_basis = 0.0 # Track total amount spent on current position for average price calc
self.entry_time: Optional[int] = None
self.trades: List[Dict[str, Any]] = []
self.running = False
self.progress = 0
self.total_klines = 0
self.last_kline_price: Optional[float] = None # Track last price for open position valuation
async def run(self) -> Dict[str, Any]: async def run(self) -> Dict[str, Any]:
raise NotImplementedError("Backtest engine not yet implemented") self.running = True
self.status = "running"
started_at = datetime.utcnow()
try:
chain = self.config.get("chain", "bsc")
timeframe = self.config.get("timeframe", "1h")
start_date = self.config.get("start_date", "")
end_date = self.config.get("end_date", "")
# Get token address from strategy config (saved when user confirmed token)
token_address = None
token_symbol = None
# Try to get from conditions first
if self.conditions:
token_address = self.conditions[0].get("token_address")
token_symbol = self.conditions[0].get("token")
# Fallback to actions
if not token_address and self.actions:
token_address = self.actions[0].get("token_address")
token_symbol = self.actions[0].get("token") or token_symbol
if not token_address:
raise ValueError("Token address not found in strategy. Please update your strategy with a valid token.")
token_id = token_address
start_ts = None
end_ts = None
if start_date:
start_ts = int(
datetime.fromisoformat(
start_date.replace("Z", "+00:00")
).timestamp()
* 1000
)
if end_date:
end_ts = int(
datetime.fromisoformat(end_date.replace("Z", "+00:00")).timestamp()
* 1000
)
klines = await self.ave_client.get_klines(
token_id=token_id,
interval=timeframe,
limit=1000,
start_time=start_ts,
end_time=end_ts,
)
if not klines:
self.status = "failed"
self.results = {"error": "No kline data available"}
return self.results
await self._process_klines(klines)
self._calculate_metrics()
self.status = "completed"
except Exception as e:
self.status = "failed"
self.results = {"error": str(e)}
ended_at = datetime.utcnow()
self.results = self.results or {}
self.results["started_at"] = started_at
self.results["ended_at"] = ended_at
self.results["duration_seconds"] = (ended_at - started_at).total_seconds()
return self.results
async def run_with_klines(self, klines: List[Dict[str, Any]]):
"""Test helper method that runs backtest with provided klines (bypasses API call)."""
self.running = True
self.status = "running"
started_at = datetime.utcnow()
try:
if not klines:
self.status = "failed"
self.results = {"error": "No kline data available"}
return self.results
await self._process_klines(klines)
self._calculate_metrics()
self.status = "completed"
except Exception as e:
self.status = "failed"
self.results = {"error": str(e)}
ended_at = datetime.utcnow()
self.results = self.results or {}
self.results["started_at"] = started_at
self.results["ended_at"] = ended_at
self.results["duration_seconds"] = (ended_at - started_at).total_seconds()
return self.results
async def _process_klines(self, klines: List[Dict[str, Any]]):
self.total_klines = len(klines)
for i, kline in enumerate(klines):
if not self.running:
break
self.progress = int((i / self.total_klines) * 100) if self.total_klines > 0 else 0
price = float(kline.get("close", 0))
if price <= 0:
continue
self.last_kline_price = price # Track last price for open position valuation
timestamp = kline.get("timestamp", 0)
if self.position > 0 and self.entry_price is not None:
exit_info = self._check_risk_management(price, timestamp)
if exit_info:
await self._execute_risk_exit(price, timestamp, exit_info)
continue
for condition in self.conditions:
if self._check_condition(condition, klines, i, price):
await self._execute_actions(price, timestamp, condition)
break
@property
def average_entry_price(self) -> Optional[float]:
"""Calculate weighted average entry price based on cost basis."""
if self.position <= 0 or self.cost_basis <= 0:
return None
return self.cost_basis / self.position
def _check_risk_management(
self, current_price: float, timestamp: int
) -> Optional[Dict[str, Any]]:
if self.position <= 0 or self.average_entry_price is None:
return None
if self.stop_loss_percent is not None:
stop_loss_price = self.average_entry_price * (1 - self.stop_loss_percent / 100)
if current_price <= stop_loss_price:
return {"reason": "stop_loss", "price": stop_loss_price}
if self.take_profit_percent is not None:
take_profit_price = self.average_entry_price * (1 + self.take_profit_percent / 100)
# Use small epsilon to handle floating point precision
if current_price >= take_profit_price - 0.001:
return {"reason": "take_profit", "price": take_profit_price}
return None
async def _execute_risk_exit(
self, price: float, timestamp: int, exit_info: Dict[str, Any]
):
if self.position <= 0:
return
reason = exit_info["reason"]
sell_amount = self.position * price
self.current_balance += sell_amount
self.trades.append(
{
"type": "sell",
"token": self.position_token,
"price": price,
"amount": sell_amount,
"quantity": self.position,
"timestamp": timestamp,
"exit_reason": reason,
}
)
self.signals.append(
{
"id": str(uuid.uuid4()),
"bot_id": self.bot_id,
"run_id": self.run_id,
"signal_type": "sell",
"token": self.position_token,
"price": price,
"confidence": 1.0,
"reasoning": f"Risk management triggered {reason}",
"executed": False,
"created_at": datetime.utcnow(),
}
)
self.position = 0
self.entry_price = None
self.cost_basis = 0.0
self.entry_time = None
def _check_condition(
self,
condition: Dict[str, Any],
klines: List[Dict[str, Any]],
current_idx: int,
current_price: float,
) -> bool:
cond_type = condition.get("type", "")
threshold = condition.get("threshold", 0)
timeframe = condition.get("timeframe", "1h")
price_level = condition.get("price")
direction = condition.get("direction", "above")
if cond_type == "price_drop":
if current_idx == 0:
return False
prev_price = float(klines[current_idx - 1].get("close", 0))
if prev_price <= 0:
return False
drop_pct = ((prev_price - current_price) / prev_price) * 100
return drop_pct >= threshold
elif cond_type == "price_rise":
if current_idx == 0:
return False
prev_price = float(klines[current_idx - 1].get("close", 0))
if prev_price <= 0:
return False
rise_pct = ((current_price - prev_price) / prev_price) * 100
return rise_pct >= threshold
elif cond_type == "volume_spike":
if current_idx == 0:
return False
prev_volume = float(klines[current_idx - 1].get("volume", 0))
current_volume = float(kline.get("volume", 0))
if prev_volume <= 0:
return False
volume_increase = ((current_volume - prev_volume) / prev_volume) * 100
return volume_increase >= threshold
elif cond_type == "price_level":
if price_level is None:
return False
if direction == "above":
return current_price > price_level
else:
return current_price < price_level
return False
async def _execute_actions(
self, price: float, timestamp: int, matched_condition: Dict[str, Any]
):
token = matched_condition.get("token", self.config.get("token", ""))
for action in self.actions:
action_type = action.get("type", "")
amount_percent = action.get("amount_percent", 10)
amount = self.current_balance * (amount_percent / 100)
if action_type == "buy" and self.current_balance >= amount:
quantity = amount / price
self.position += quantity
self.current_balance -= amount
self.cost_basis += amount # Track total cost for average price
self.position_token = token
self.entry_price = price # Keep last entry price for reference
self.entry_time = timestamp
self.trades.append(
{
"type": "buy",
"token": token,
"price": price,
"amount": amount,
"quantity": quantity,
"timestamp": timestamp,
}
)
self.signals.append(
{
"id": str(uuid.uuid4()),
"bot_id": self.bot_id,
"run_id": self.run_id,
"signal_type": "buy",
"token": token,
"price": price,
"confidence": 0.8,
"reasoning": f"Condition {matched_condition.get('type')} triggered buy",
"executed": False,
"created_at": datetime.utcnow(),
}
)
elif action_type == "sell" and self.position > 0:
sell_amount = self.position * price
self.current_balance += sell_amount
self.trades.append(
{
"type": "sell",
"token": self.position_token,
"price": price,
"amount": sell_amount,
"quantity": self.position,
"timestamp": timestamp,
"exit_reason": "manual",
}
)
self.position = 0
self.entry_price = None
self.entry_time = None
self.signals.append(
{
"id": str(uuid.uuid4()),
"bot_id": self.bot_id,
"run_id": self.run_id,
"signal_type": "sell",
"token": self.position_token,
"price": price,
"confidence": 0.8,
"reasoning": f"Condition {matched_condition.get('type')} triggered sell",
"executed": False,
"created_at": datetime.utcnow(),
}
)
def _calculate_metrics(self):
# For open positions, use the last kline price to mark to market
# If no last kline price, fall back to entry price
position_price = self.last_kline_price
if position_price is None and self.trades and self.position > 0:
position_price = self.trades[-1]["price"] # Fall back to entry price
# Calculate final balance: use marked-to-market value if position open, otherwise current balance
if self.position > 0 and position_price:
final_balance = self.current_balance + self.position * position_price
else:
final_balance = self.current_balance
total_return = (
(final_balance - self.initial_balance) / self.initial_balance
) * 100
buy_trades = [t for t in self.trades if t["type"] == "buy"]
sell_trades = [t for t in self.trades if t["type"] == "sell"]
total_trades = len(buy_trades) + len(sell_trades)
winning_trades = 0
for i, trade in enumerate(sell_trades):
if i < len(buy_trades):
buy_price = buy_trades[i]["price"]
sell_price = trade["price"]
if sell_price > buy_price:
winning_trades += 1
win_rate = (winning_trades / len(sell_trades) * 100) if sell_trades else 0
portfolio_values = []
running_balance = self.initial_balance
running_position = 0.0
current_token = ""
last_price = 0.0
for trade in self.trades:
if trade["type"] == "buy":
running_position += trade["quantity"] # Add to existing position (DCA)
running_balance -= trade["amount"] # Subtract amount spent
current_token = trade["token"]
last_price = trade["price"]
else: # sell
running_balance += trade["amount"] # Add amount received
running_position = 0 # Close entire position
last_price = trade["price"]
portfolio_value = running_balance + (running_position * last_price)
portfolio_values.append(portfolio_value)
# If there's an open position, add final marked-to-market value
if self.position > 0 and self.last_kline_price:
final_portfolio_value = self.current_balance + (self.position * self.last_kline_price)
portfolio_values.append(final_portfolio_value)
max_value = self.initial_balance
max_drawdown = 0.0
for value in portfolio_values:
if value > max_value:
max_value = value
drawdown = ((max_value - value) / max_value) * 100
if drawdown > max_drawdown:
max_drawdown = drawdown
sharpe_ratio = 0.0
if len(portfolio_values) > 1:
returns = []
for i in range(1, len(portfolio_values)):
ret = (
portfolio_values[i] - portfolio_values[i - 1]
) / portfolio_values[i - 1]
returns.append(ret)
if returns:
avg_return = sum(returns) / len(returns)
variance = sum((r - avg_return) ** 2 for r in returns) / len(returns)
std_dev = variance**0.5
if std_dev > 0:
sharpe_ratio = avg_return / std_dev
buy_signals = len(buy_trades)
sell_signals = len(sell_trades)
self.results = {
"total_return": round(total_return, 2),
"win_rate": round(win_rate, 2),
"total_trades": total_trades,
"buy_signals": buy_signals,
"sell_signals": sell_signals,
"max_drawdown": round(max_drawdown, 2),
"sharpe_ratio": round(sharpe_ratio, 2),
"final_balance": round(final_balance, 2),
"signals": self.signals,
"trades": self.trades, # Include trades in results for storage
}
async def stop(self): async def stop(self):
raise NotImplementedError("Backtest stop not yet implemented") self.running = False
self.progress = 0
self.total_klines = 0
self.status = "stopped"
self._calculate_metrics()
def get_results(self) -> Dict[str, Any]: def get_results(self) -> Dict[str, Any]:
raise NotImplementedError("Backtest results not yet implemented") return {
"id": self.run_id,
"status": self.status,
"results": self.results,
"signals": self.signals,
"progress": self.progress,
"total_klines": self.total_klines,
}
def get_status(self) -> Dict[str, Any]:
return {
"status": self.status,
"progress": self.progress,
"total_klines": self.total_klines,
}

View File

@@ -1,15 +1,437 @@
from typing import Optional, Dict, Any, List import uuid
import asyncio
import logging
from datetime import datetime
from typing import Dict, Any, List, Optional
from ..ave.client import AveCloudClient
logger = logging.getLogger(__name__)
class SimulateEngine: class SimulateEngine:
def __init__(self, config: Dict[str, Any]): def __init__(self, config: Dict[str, Any]):
self.config = config self.config = config
self.run_id = str(uuid.uuid4())
self.status = "pending"
self.results: Optional[Dict[str, Any]] = None
self.signals: List[Dict[str, Any]] = []
self.ave_client = AveCloudClient(
api_key=config.get("ave_api_key", ""),
plan=config.get("ave_api_plan", "free"),
)
self.bot_id = config.get("bot_id")
self.strategy_config = config.get("strategy_config", {})
self.conditions = self.strategy_config.get("conditions", [])
self.actions = self.strategy_config.get("actions", [])
self.risk_management = self.strategy_config.get("risk_management", {})
self.stop_loss_percent = self.risk_management.get("stop_loss_percent")
self.take_profit_percent = self.risk_management.get("take_profit_percent")
async def run(self) -> List[Dict[str, Any]]: # Kline-based settings
raise NotImplementedError("Simulation engine not yet implemented") self.kline_interval = config.get("kline_interval", "1m")
self.max_candles = config.get("max_candles", 100) # Limit candles to simulate real-time
async def stop(self): # Delay between candles (in seconds) to simulate real-time
raise NotImplementedError("Simulation stop not yet implemented") # e.g., 1m interval -> 30s delay between candles
# Use config value if provided, otherwise calculate
if "candle_delay" in config and config["candle_delay"] is not None:
self.candle_delay = config["candle_delay"]
else:
self.candle_delay = self._get_interval_seconds(self.kline_interval) / 2
self.auto_execute = config.get("auto_execute", False)
self.token = config.get("token", "")
self.chain = config.get("chain", "bsc")
self.running = False
self.started_at: Optional[datetime] = None
# Price tracking (for conditions)
self.last_close: Optional[float] = None
self.last_volume: Optional[float] = None
# Position tracking (for risk management)
self.position: float = 0.0
self.position_token: str = ""
self.entry_price: Optional[float] = None
self.entry_time: Optional[int] = None
# Portfolio
self.current_balance: float = config.get("initial_balance", 10000.0)
self.trades: List[Dict[str, Any]] = []
# Error tracking
self.errors: List[str] = []
# Kline data
self.klines: List[Dict[str, Any]] = []
self.last_processed_time: Optional[int] = None
# Trade log - tracks what happened at each candle
self.trade_log: List[Dict[str, Any]] = []
# Current candle being processed (for frontend to show progress)
self.current_candle_index = 0
self.total_candles = 0
def _get_interval_seconds(self, interval: str) -> int:
"""Convert kline interval to seconds."""
mapping = {
"1m": 60,
"5m": 300,
"15m": 900,
"30m": 1800,
"1h": 3600,
"4h": 14400,
"1d": 86400,
}
return mapping.get(interval, 60)
async def run(self) -> Dict[str, Any]:
self.running = True
self.status = "running"
self.started_at = datetime.utcnow()
token_id = (
f"{self.token}-{self.chain}"
if self.token and not self.token.endswith(f"-{self.chain}")
else self.token
)
if not token_id or token_id == f"-{self.chain}":
self.status = "failed"
self.results = {"error": "Token ID is required"}
return self.results
try:
# Step 1: Fetch klines (only once for simulation)
self.klines = await self._fetch_klines(token_id)
if not self.klines:
self.status = "failed"
self.results = {"error": "No kline data available"}
return self.results
logger.info(f"Fetched {len(self.klines)} klines for {token_id}")
# Step 2: Process candles (with limit)
candles_processed = 0
self.total_candles = min(len(self.klines), self.max_candles)
self.current_candle_index = 0
for i, candle in enumerate(self.klines):
if not self.running:
break
if candles_processed >= self.max_candles:
logger.info(f"Reached max candles limit ({self.max_candles})")
break
self.current_candle_index = candles_processed
candle_time = int(candle.get("time", 0))
# Get OHLCV data from candle
close_price = float(candle.get("close", 0))
volume = float(candle.get("volume", 0))
if close_price > 0:
# Process candle
await self._process_candle(close_price, volume, candle_time)
# Update last close for next iteration
self.last_close = close_price
self.last_volume = volume
# Track last processed time
self.last_processed_time = candle_time
candles_processed += 1
# Delay to simulate real-time (only for visible candles, not initial batch)
if candles_processed > 1 and self.candle_delay > 0:
await asyncio.sleep(self.candle_delay)
self.status = "completed"
except Exception as e:
logger.error(f"Simulation error: {e}")
self.status = "failed"
self.results = {"error": str(e)}
self.errors.append(str(e))
self.results = self.results or {}
self.results["total_signals"] = len(self.signals)
self.results["total_trades"] = len(self.trades)
self.results["total_errors"] = len(self.errors)
self.results["errors"] = self.errors
self.results["signals"] = self.signals
self.results["candles_processed"] = candles_processed
self.results["current_candle_index"] = self.current_candle_index
self.results["total_candles"] = self.total_candles
self.results["klines"] = self.klines # Include klines for chart display
self.results["trade_log"] = self.trade_log # Include trade log for dashboard
self.results["portfolio"] = {
"initial_balance": self.config.get("initial_balance", 10000),
"current_balance": self.current_balance,
"position": self.position,
"position_token": self.position_token,
"entry_price": self.entry_price,
"current_price": self.last_close,
}
self.results["started_at"] = self.started_at
self.results["ended_at"] = datetime.utcnow()
return self.results
async def _fetch_klines(
self,
token_id: str,
limit: int = 500
) -> List[Dict[str, Any]]:
"""Fetch klines from AVE API."""
try:
klines = await self.ave_client.get_klines(
token_id,
interval=self.kline_interval,
limit=limit
)
# Sort by time ascending (oldest first)
klines = sorted(klines, key=lambda x: x.get("time", 0))
return klines
except Exception as e:
logger.warning(f"Failed to fetch klines for {token_id}: {e}")
self.errors.append(f"Kline fetch failed: {str(e)}")
return []
async def _process_candle(
self,
close_price: float,
volume: float,
timestamp: int
):
"""Process a single candle - check conditions and risk management."""
action = "hold" # Default action
reason = ""
# Check risk management first (for open positions)
if self.position > 0 and self.entry_price is not None:
exit_info = self._check_risk_management(close_price, timestamp)
if exit_info:
await self._execute_risk_exit(close_price, timestamp, exit_info)
action = "sell"
reason = exit_info["reason"]
# Log the action
self.trade_log.append({
"time": timestamp,
"price": close_price,
"action": action,
"reason": reason,
"position": self.position,
"entry_price": self.entry_price,
})
return
# Check conditions (only if no open position)
if self.position == 0:
for condition in self.conditions:
if self._check_condition(condition, close_price, volume):
await self._execute_actions(close_price, timestamp, condition)
action = "buy"
reason = f"{condition.get('type')} {condition.get('threshold')}%".format(
type=condition.get('type'),
threshold=condition.get('threshold')
)
# Log the action
self.trade_log.append({
"time": timestamp,
"price": close_price,
"action": action,
"reason": reason,
"position": self.position,
"entry_price": self.entry_price,
})
break
# Log hold action (no signal)
if action == "hold":
# Only log every 10th candle to reduce data
if len(self.trade_log) == 0 or (len(self.klines) - len(self.trade_log) > 10):
self.trade_log.append({
"time": timestamp,
"price": close_price,
"action": "hold",
"reason": "no_signal",
"position": self.position,
"entry_price": self.entry_price,
})
def _check_risk_management(
self, current_price: float, timestamp: int
) -> Optional[Dict[str, Any]]:
"""Check if stop loss or take profit is triggered."""
if self.position <= 0 or self.entry_price is None:
return None
if self.stop_loss_percent is not None:
stop_loss_price = self.entry_price * (1 - self.stop_loss_percent / 100)
if current_price <= stop_loss_price:
return {"reason": "stop_loss", "price": stop_loss_price}
if self.take_profit_percent is not None:
take_profit_price = self.entry_price * (1 + self.take_profit_percent / 100)
if current_price >= take_profit_price:
return {"reason": "take_profit", "price": take_profit_price}
return None
async def _execute_risk_exit(
self, price: float, timestamp: int, exit_info: Dict[str, Any]
):
"""Execute stop loss or take profit."""
if self.position <= 0:
return
reason = exit_info["reason"]
quantity = self.position
sale_proceeds = quantity * price
# Add sale proceeds to cash balance
self.current_balance += sale_proceeds
self.trades.append(
{
"type": "sell",
"token": self.position_token,
"price": price,
"quantity": quantity,
"amount": sale_proceeds,
"timestamp": timestamp,
"exit_reason": reason,
}
)
self.signals.append(
{
"id": str(uuid.uuid4()),
"bot_id": self.bot_id,
"run_id": self.run_id,
"signal_type": "sell",
"token": self.position_token,
"price": price,
"confidence": 1.0,
"reasoning": f"Risk management triggered {reason}",
"executed": self.auto_execute,
"created_at": datetime.utcnow(),
}
)
self.position = 0
self.entry_price = None
self.entry_time = None
def _check_condition(
self,
condition: Dict[str, Any],
current_price: float,
current_volume: float,
) -> bool:
"""Check if a condition is met based on price movement."""
cond_type = condition.get("type", "")
threshold = condition.get("threshold", 0)
if cond_type == "price_drop":
# Price dropped by threshold % from last close
if self.last_close is None or self.last_close <= 0:
return False
drop_pct = ((self.last_close - current_price) / self.last_close) * 100
return drop_pct >= threshold
elif cond_type == "price_rise":
# Price rose by threshold % from last close
if self.last_close is None or self.last_close <= 0:
return False
rise_pct = ((current_price - self.last_close) / self.last_close) * 100
return rise_pct >= threshold
elif cond_type == "volume_spike":
# Volume increased significantly
if self.last_volume is None or self.last_volume <= 0:
return False
volume_increase = ((current_volume - self.last_volume) / self.last_volume) * 100
return volume_increase >= threshold
elif cond_type == "price_level":
price_level = condition.get("price")
direction = condition.get("direction", "above")
if price_level is None:
return False
if direction == "above":
return current_price > price_level
else:
return current_price < price_level
return False
async def _execute_actions(
self, price: float, timestamp: int, matched_condition: Dict[str, Any]
):
"""Execute buy/sell actions based on matched condition."""
token = matched_condition.get("token", self.token)
reasoning = f"Condition {matched_condition.get('type')} triggered"
for action in self.actions:
action_type = action.get("type", "")
if action_type == "buy":
amount_percent = action.get("amount_percent", 10)
amount = self.current_balance * (amount_percent / 100)
quantity = amount / price
self.position += quantity
self.position_token = token
self.entry_price = price
self.entry_time = timestamp
self.current_balance -= amount
self.trades.append(
{
"type": "buy",
"token": token,
"price": price,
"amount": amount,
"quantity": quantity,
"timestamp": timestamp,
}
)
signal = {
"id": str(uuid.uuid4()),
"bot_id": self.bot_id,
"run_id": self.run_id,
"signal_type": action_type,
"token": token,
"price": price,
"confidence": 0.8,
"reasoning": reasoning,
"executed": self.auto_execute,
"created_at": datetime.utcnow(),
}
self.signals.append(signal)
def stop(self):
"""Stop the simulation."""
self.running = False
self.status = "stopped"
def get_results(self) -> Dict[str, Any]:
"""Get simulation results."""
return {
"id": self.run_id,
"status": self.status,
"results": self.results,
"signals": self.signals,
}
def get_signals(self) -> List[Dict[str, Any]]: def get_signals(self) -> List[Dict[str, Any]]:
raise NotImplementedError("Simulation signals not yet implemented") """Get current signals."""
return self.signals

View File

@@ -6,6 +6,7 @@ pydantic-settings>=2.1.0
email-validator>=2.0.0 email-validator>=2.0.0
python-jose[cryptography]>=3.3.0 python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4 passlib[bcrypt]>=1.7.4
bcrypt>=4.0,<5.0 # Required for passlib compatibility
crewai>=0.1.0 crewai>=0.1.0
anthropic>=0.18.0 anthropic>=0.18.0
httpx>=0.26.0 httpx>=0.26.0

View File

@@ -8,4 +8,5 @@ if __name__ == "__main__":
host=settings.HOST, host=settings.HOST,
port=settings.PORT, port=settings.PORT,
reload=settings.DEBUG, reload=settings.DEBUG,
timeout_keep_alive=300,
) )

View File

@@ -0,0 +1,457 @@
"""
Unit tests for BacktestEngine
Tests stop loss, take profit, and max drawdown calculations
"""
import asyncio
from app.services.backtest.engine import BacktestEngine
class TestBacktestEngine:
"""Test suite for BacktestEngine"""
def _run_backtest(self, config, klines):
"""Helper to run backtest with given klines"""
engine = BacktestEngine(config)
result = asyncio.run(engine.run_with_klines(klines))
return engine, result
def _trace_portfolio(self, engine, initial_balance):
"""Print portfolio trace for debugging"""
running_balance = initial_balance
running_position = 0.0
print("\nPortfolio Trace:")
for i, trade in enumerate(engine.trades):
if trade["type"] == "buy":
running_position = trade["quantity"]
running_balance -= trade["amount"]
portfolio = running_balance + (running_position * trade["price"])
print(f" BUY #{i+1}: @${trade['price']} - portfolio=${portfolio:.2f}")
else:
running_balance += trade["amount"]
running_position = 0
portfolio = running_balance
print(f" SELL #{i+1}: @${trade['price']} ({trade.get('exit_reason', '')}) - portfolio=${portfolio:.2f}")
if engine.position > 0 and engine.last_kline_price:
final = running_balance + (engine.position * engine.last_kline_price)
print(f" FINAL: position={engine.position:.2f} @ ${engine.last_kline_price} = ${final:.2f}")
print()
def test_stop_loss_triggers_correctly(self):
"""Test stop loss triggers at configured percentage"""
config = {
"bot_id": "test",
"strategy_config": {
"conditions": [{"type": "price_drop", "token": "TEST", "token_address": "0x123", "threshold": 5}],
"actions": [{"type": "buy", "amount_percent": 100}],
"risk_management": {"stop_loss_percent": 5, "take_profit_percent": 10}
},
"ave_api_key": "test",
"ave_api_plan": "free",
"initial_balance": 10000.0,
}
# Price sequence that triggers buy then stop loss:
# $110 -> $100 (9% drop, BUY)
# $100 -> $95 (5% drop, STOP LOSS at 5% from $100 = $95)
klines = [
{"close": "110.0", "timestamp": 1000, "open": "110.0", "high": "110.0", "low": "110.0", "volume": "1000"},
{"close": "100.0", "timestamp": 2000, "open": "100.0", "high": "100.0", "low": "100.0", "volume": "1000"},
{"close": "95.0", "timestamp": 3000, "open": "95.0", "high": "95.0", "low": "95.0", "volume": "1000"},
]
engine, result = self._run_backtest(config, klines)
self._trace_portfolio(engine, 10000.0)
print(f"Results:")
print(f" Trades: {len(engine.trades)} (expected 2)")
print(f" Max drawdown: {result['max_drawdown']}%")
print(f" Total return: {result['total_return']}%")
assert len(engine.trades) == 2
assert engine.trades[0]["type"] == "buy"
assert engine.trades[1]["type"] == "sell"
assert engine.trades[1]["exit_reason"] == "stop_loss"
# Max drawdown should be ~5% (stop loss percentage)
assert 3 < result['max_drawdown'] < 8
# Total return should be ~-5%
assert -8 < result['total_return'] < -3
def test_take_profit_triggers(self):
"""Test take profit triggers at configured percentage"""
config = {
"bot_id": "test",
"strategy_config": {
"conditions": [{"type": "price_drop", "token": "TEST", "token_address": "0x123", "threshold": 5}],
"actions": [{"type": "buy", "amount_percent": 100}],
"risk_management": {"stop_loss_percent": 5, "take_profit_percent": 10}
},
"ave_api_key": "test",
"ave_api_plan": "free",
"initial_balance": 10000.0,
}
# $100 -> $95 (5% drop, BUY) -> $104.5 (10% rise, TAKE PROFIT)
klines = [
{"close": "100.0", "timestamp": 1000, "open": "100.0", "high": "100.0", "low": "100.0", "volume": "1000"},
{"close": "95.0", "timestamp": 2000, "open": "95.0", "high": "95.0", "low": "95.0", "volume": "1000"},
{"close": "104.5", "timestamp": 3000, "open": "104.5", "high": "104.5", "low": "104.5", "volume": "1000"},
]
engine, result = self._run_backtest(config, klines)
self._trace_portfolio(engine, 10000.0)
print(f"Results:")
print(f" Trades: {len(engine.trades)} (expected 2)")
print(f" Max drawdown: {result['max_drawdown']}%")
print(f" Total return: {result['total_return']}%")
assert len(engine.trades) == 2
assert engine.trades[1]["exit_reason"] == "take_profit"
assert result['total_return'] > 0
def test_max_drawdown_bounded_by_stop_loss(self):
"""Test that max drawdown is bounded by stop loss when position is properly closed"""
config = {
"bot_id": "test",
"strategy_config": {
"conditions": [{"type": "price_drop", "token": "TEST", "token_address": "0x123", "threshold": 5}],
"actions": [{"type": "buy", "amount_percent": 100}],
"risk_management": {"stop_loss_percent": 5, "take_profit_percent": 10}
},
"ave_api_key": "test",
"ave_api_plan": "free",
"initial_balance": 10000.0,
}
# $110 -> $100 -> $95 (BUY) -> $90 (STOP LOSS)
klines = [
{"close": "110.0", "timestamp": 1000, "open": "110.0", "high": "110.0", "low": "110.0", "volume": "1000"},
{"close": "100.0", "timestamp": 2000, "open": "100.0", "high": "100.0", "low": "100.0", "volume": "1000"},
{"close": "95.0", "timestamp": 3000, "open": "95.0", "high": "95.0", "low": "95.0", "volume": "1000"},
{"close": "90.0", "timestamp": 4000, "open": "90.0", "high": "90.0", "low": "90.0", "volume": "1000"},
]
engine, result = self._run_backtest(config, klines)
self._trace_portfolio(engine, 10000.0)
print(f"Results:")
print(f" Trades: {len(engine.trades)}")
print(f" Max drawdown: {result['max_drawdown']}%")
print(f" Total return: {result['total_return']}%")
# With 5% stop loss, max drawdown should be around 5%
assert 3 < result['max_drawdown'] < 8
def test_open_position_not_closed(self):
"""Test scenario where last kline has an open position"""
config = {
"bot_id": "test",
"strategy_config": {
"conditions": [{"type": "price_drop", "token": "TEST", "token_address": "0x123", "threshold": 10}],
"actions": [{"type": "buy", "amount_percent": 100}],
"risk_management": {"stop_loss_percent": 5, "take_profit_percent": 10}
},
"ave_api_key": "test",
"ave_api_plan": "free",
"initial_balance": 10000.0,
}
# $100 -> $90 (10% drop, BUY) - and backtest ends here
# Position is open, marked to market at $90
klines = [
{"close": "100.0", "timestamp": 1000, "open": "100.0", "high": "100.0", "low": "100.0", "volume": "1000"},
{"close": "90.0", "timestamp": 2000, "open": "90.0", "high": "90.0", "low": "90.0", "volume": "1000"},
]
engine, result = self._run_backtest(config, klines)
self._trace_portfolio(engine, 10000.0)
print(f"Results:")
print(f" Trades: {len(engine.trades)}")
print(f" Position open: {engine.position > 0}")
print(f" Entry price: ${engine.entry_price}")
print(f" Last kline price: ${engine.last_kline_price}")
print(f" Max drawdown: {result['max_drawdown']}%")
print(f" Total return: {result['total_return']}%")
# Position should be open
assert engine.position > 0
# Entry should be $90
assert engine.entry_price == 90.0
# Since entry = last kline price, no unrealized loss
# Max drawdown should be 0%
assert result['max_drawdown'] == 0.0
def test_open_position_with_loss(self):
"""Test open position where price dropped but stop loss didn't trigger"""
config = {
"bot_id": "test",
"strategy_config": {
"conditions": [{"type": "price_drop", "token": "TEST", "token_address": "0x123", "threshold": 10}],
"actions": [{"type": "buy", "amount_percent": 100}],
"risk_management": {"stop_loss_percent": 5, "take_profit_percent": 10}
},
"ave_api_key": "test",
"ave_api_plan": "free",
"initial_balance": 10000.0,
}
# $100 -> $90 (10% drop, BUY at $90) -> $85 (stop loss at 5% from $90 = $85.5)
# $85 > $85.5? No, $85 < $85.5, so stop loss WOULD trigger
# Let me use $86 instead - $86 > $85.5 so no stop loss
klines = [
{"close": "100.0", "timestamp": 1000, "open": "100.0", "high": "100.0", "low": "100.0", "volume": "1000"},
{"close": "90.0", "timestamp": 2000, "open": "90.0", "high": "90.0", "low": "90.0", "volume": "1000"},
{"close": "86.0", "timestamp": 3000, "open": "86.0", "high": "86.0", "low": "86.0", "volume": "1000"},
]
engine, result = self._run_backtest(config, klines)
self._trace_portfolio(engine, 10000.0)
print(f"Results:")
print(f" Trades: {len(engine.trades)}")
print(f" Position open: {engine.position > 0}")
print(f" Entry price: ${engine.entry_price}")
print(f" Last kline price: ${engine.last_kline_price}")
print(f" Max drawdown: {result['max_drawdown']}%")
print(f" Total return: {result['total_return']}%")
# Position should be open
assert engine.position > 0
# Entry = $90, stop = $85.50, last = $86 (above stop)
# Portfolio: $0 + position * $86
# Position: 10000/90 = 111.11 tokens
# Portfolio at $86: 111.11 * 86 = $9,555.56
# But we only track portfolio at trade points, so max was $10,000
# drawdown = (10000 - 9555.56) / 10000 = 4.44%
print(f" Expected max drawdown: ~4.4% (marked to market at $86)")
def test_multiple_buy_sell_cycles(self):
"""Test multiple buy/sell cycles"""
config = {
"bot_id": "test",
"strategy_config": {
"conditions": [{"type": "price_drop", "token": "TEST", "token_address": "0x123", "threshold": 5}],
"actions": [{"type": "buy", "amount_percent": 50}], # 50% of balance
"risk_management": {"stop_loss_percent": 5, "take_profit_percent": 10}
},
"ave_api_key": "test",
"ave_api_plan": "free",
"initial_balance": 10000.0,
}
# $100 -> $95 (BUY) -> $104.5 (TAKE PROFIT) -> $95 (BUY) -> $90 (STOP LOSS)
klines = [
{"close": "100.0", "timestamp": 1000, "open": "100.0", "high": "100.0", "low": "100.0", "volume": "1000"},
{"close": "95.0", "timestamp": 2000, "open": "95.0", "high": "95.0", "low": "95.0", "volume": "1000"}, # BUY at $95
{"close": "104.5", "timestamp": 3000, "open": "104.5", "high": "104.5", "low": "104.5", "volume": "1000"}, # TAKE PROFIT
{"close": "95.0", "timestamp": 4000, "open": "95.0", "high": "95.0", "low": "95.0", "volume": "1000"}, # 9% drop - no buy
{"close": "90.0", "timestamp": 5000, "open": "90.0", "high": "90.0", "low": "90.0", "volume": "1000"}, # 10.5% drop from $100 - BUY at $90
{"close": "85.5", "timestamp": 6000, "open": "85.5", "high": "85.5", "low": "85.5", "volume": "1000"}, # STOP LOSS at 5% from $90 = $85.5
]
engine, result = self._run_backtest(config, klines)
self._trace_portfolio(engine, 10000.0)
print(f"Results:")
print(f" Trades: {len(engine.trades)}")
print(f" Buy count: {len([t for t in engine.trades if t['type'] == 'buy'])}")
print(f" Sell count: {len([t for t in engine.trades if t['type'] == 'sell'])}")
print(f" Max drawdown: {result['max_drawdown']}%")
print(f" Total return: {result['total_return']}%")
def run_tests():
tests = TestBacktestEngine()
print("=" * 60)
print("TEST 1: Stop Loss Triggers Correctly")
print("=" * 60)
try:
tests.test_stop_loss_triggers_correctly()
print("PASSED\n")
except AssertionError as e:
print(f"FAILED: {e}\n")
print("=" * 60)
print("TEST 2: Take Profit Triggers")
print("=" * 60)
try:
tests.test_take_profit_triggers()
print("PASSED\n")
except AssertionError as e:
print(f"FAILED: {e}\n")
print("=" * 60)
print("TEST 3: Max Drawdown Bounded by Stop Loss")
print("=" * 60)
try:
tests.test_max_drawdown_bounded_by_stop_loss()
print("PASSED\n")
except AssertionError as e:
print(f"FAILED: {e}\n")
print("=" * 60)
print("TEST 4: Open Position Not Closed")
print("=" * 60)
try:
tests.test_open_position_not_closed()
print("PASSED\n")
except AssertionError as e:
print(f"FAILED: {e}\n")
print("=" * 60)
print("TEST 5: Open Position With Loss")
print("=" * 60)
try:
tests.test_open_position_with_loss()
print("PASSED\n")
except AssertionError as e:
print(f"FAILED: {e}\n")
print("=" * 60)
print("TEST 6: Multiple Buy/Sell Cycles")
print("=" * 60)
try:
tests.test_multiple_buy_sell_cycles()
print("PASSED\n")
except AssertionError as e:
print(f"FAILED: {e}\n")
def test_dca_multiple_buys():
"""Test that DCA with multiple consecutive buys uses weighted average for stop loss."""
print("\n" + "=" * 60)
print("TEST 7: DCA With Multiple Consecutive Buys")
print("=" * 60)
config = {
"bot_id": "test",
"strategy_config": {
"conditions": [{"type": "price_drop", "threshold": 2, "token": "TEST", "token_address": "0x123"}],
"actions": [{"type": "buy", "amount_percent": 20}],
"risk_management": {"stop_loss_percent": 5, "take_profit_percent": 5},
},
"initial_balance": 10000.0,
"ave_api_key": "test",
"ave_api_plan": "free",
}
# 3 consecutive 2% drops = 3 buys at $0.58, $0.57, $0.56
# Then drop to $0.50 which is below 5% from average (~$0.57 * 0.95 = $0.54)
klines = [
{"close": "0.60", "timestamp": 1000, "open": "0.60", "high": "0.60", "low": "0.60", "volume": "1000"},
{"close": "0.588", "timestamp": 2000}, # 2% drop -> BUY 1 @ $0.588
{"close": "0.576", "timestamp": 3000}, # 2% drop -> BUY 2 @ $0.576
{"close": "0.565", "timestamp": 4000}, # 2% drop -> BUY 3 @ $0.565
{"close": "0.50", "timestamp": 5000}, # Below 5% from avg -> STOP LOSS
]
test = TestBacktestEngine()
engine, result = test._run_backtest(config, klines)
test._trace_portfolio(engine, 10000.0)
print(f"\nResults:")
print(f" Trades: {len(engine.trades)} (expected 3: 2 buys + stop loss)")
print(f" Max drawdown: {result['max_drawdown']}%")
print(f" Total return: {result['total_return']}%")
# Verify: 2 buys + 1 sell (stop loss) = 3 trades
# The 3rd buy @ $0.565 doesn't happen because stop loss triggers at $0.5 first
assert len(engine.trades) == 3, f"Expected 3 trades, got {len(engine.trades)}"
# Verify last trade is stop loss
last_trade = engine.trades[-1]
assert last_trade["type"] == "sell", "Last trade should be sell"
assert last_trade.get("exit_reason") == "stop_loss", f"Last trade should be stop_loss, got {last_trade.get('exit_reason')}"
# Verify max drawdown is reasonable (close to stop loss %)
# Actual loss should be around 5% from weighted average
assert result['max_drawdown'] < 10, f"Max drawdown {result['max_drawdown']}% is too high for 5% stop loss"
# Position is now 0 after stop loss, so avg_entry_price is None
print(f" Position closed: {engine.position == 0}")
print(f" Final balance: ${engine.current_balance:.2f}")
print("PASSED")
return True
def test_stop_loss_always_results_in_loss():
"""Test that stop loss ALWAYS results in a loss, never a gain.
This tests the scenario where:
- You start with $10,000
- Price keeps dropping, triggering multiple buys
- Stop loss triggers, selling your entire position
- Final balance MUST be less than initial balance
"""
print("\n" + "=" * 60)
print("TEST 8: Stop Loss Always Results In Loss")
print("=" * 60)
config = {
"bot_id": "test",
"strategy_config": {
"conditions": [{"type": "price_drop", "threshold": 2, "token": "TEST", "token_address": "0x123"}],
"actions": [{"type": "buy", "amount_percent": 20}],
"risk_management": {"stop_loss_percent": 5, "take_profit_percent": 5},
},
"initial_balance": 10000.0,
"ave_api_key": "test",
"ave_api_plan": "free",
}
# Price scenario: drops each kline, triggering multiple buys
# Final drop triggers stop loss
#
# $0.60 -> $0.588 (2% drop) -> BUY 1 @ $0.588
# $0.588 -> $0.576 (2% drop) -> BUY 2 @ $0.576
# $0.576 -> $0.565 (2% drop) -> BUY 3 @ $0.565
# $0.565 -> $0.535 (5.3% drop) -> STOP LOSS @ $0.535 (5% from weighted avg ~$0.576)
klines = [
{"close": "0.60", "timestamp": 1000},
{"close": "0.588", "timestamp": 2000}, # BUY 1
{"close": "0.576", "timestamp": 3000}, # BUY 2
{"close": "0.565", "timestamp": 4000}, # BUY 3
{"close": "0.535", "timestamp": 5000}, # STOP LOSS
]
test = TestBacktestEngine()
engine, result = test._run_backtest(config, klines)
print(f"\nSetup:")
print(f" Initial balance: $10,000")
print(f" Stop loss: 5%")
print(f" Each buy: 20% of current balance")
print(f"\nTrades:")
for i, trade in enumerate(engine.trades):
exit_info = f" ({trade.get('exit_reason', '')})" if 'exit_reason' in trade else ""
print(f" {i+1}. {trade['type']} @ ${trade['price']} - ${trade['amount']:.2f}{exit_info}")
print(f"\nResults:")
print(f" Final balance: ${engine.current_balance:.2f}")
print(f" Total return: {result['total_return']:.2f}%")
print(f" Max drawdown: {result['max_drawdown']:.2f}%")
# CRITICAL ASSERTION: Stop loss MUST result in loss
assert engine.current_balance < 10000.0, \
f"BUG: Stop loss resulted in GAIN! Balance went from $10,000 to ${engine.current_balance:.2f}"
# Also verify total return is negative
assert result['total_return'] < 0, \
f"BUG: Total return is positive ({result['total_return']:.2f}%) after stop loss!"
# Max drawdown should reflect the actual loss (close to stop loss %)
assert result['max_drawdown'] < 10, \
f"Max drawdown ({result['max_drawdown']:.2f}%) seems too high"
print(f"\n✓ PASSED: Stop loss correctly resulted in ${10000 - engine.current_balance:.2f} loss")
return True
if __name__ == "__main__":
run_tests()
test_dca_multiple_buys()
test_stop_loss_always_results_in_loss()

View File

@@ -0,0 +1,386 @@
import pytest
import asyncio
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock
import sys
sys.path.insert(0, 'src/backend')
from app.services.simulate.engine import SimulateEngine
class MockAveClient:
"""Mock AVE client for testing."""
def __init__(self, klines_data=None):
self.klines_data = klines_data or []
async def get_klines(self, token_id, interval="1m", limit=100, start_time=None, end_time=None):
return self.klines_data
def create_engine(config_override=None, klines_data=None):
"""Create a test engine with mock client."""
config = {
"bot_id": "test-bot",
"token": "0x1234567890123456789012345678901234567890",
"chain": "bsc",
"kline_interval": "1m",
"max_candles": 10, # Small number for fast tests
"candle_delay": 0, # No delay in tests
"auto_execute": False,
"strategy_config": {
"conditions": [
{"type": "price_drop", "threshold": 5, "token": "TEST", "token_address": "0x1234"}
],
"actions": [
{"type": "buy", "amount_percent": 10}
],
"risk_management": {
"stop_loss_percent": 5,
"take_profit_percent": 10
}
},
"ave_api_key": "test",
"ave_api_plan": "free",
}
if config_override:
config.update(config_override)
engine = SimulateEngine(config)
engine.ave_client = MockAveClient(klines_data)
return engine
class TestSimulateEngine:
"""Unit tests for SimulateEngine."""
# ==================== Kline Fetching Tests ====================
@pytest.mark.asyncio
async def test_fetches_klines_on_start(self):
"""Engine should fetch klines when run is called."""
klines = [
{"time": 1000, "open": 100, "high": 105, "low": 98, "close": 102, "volume": 1000},
{"time": 2000, "open": 102, "high": 107, "low": 100, "close": 104, "volume": 1100},
]
engine = create_engine(klines_data=klines)
engine.running = True
results = await engine.run()
assert engine.status == "completed"
assert results["candles_processed"] == 2
@pytest.mark.asyncio
async def test_handles_no_klines_data(self):
"""Engine should handle empty klines gracefully."""
engine = create_engine(klines_data=[])
engine.running = True
results = await engine.run()
assert engine.status == "failed"
assert "error" in results
assert "No kline data" in results["error"]
# ==================== Price Drop Condition Tests ====================
@pytest.mark.asyncio
async def test_price_drop_condition_triggers_buy(self):
"""Price drop >= threshold should trigger BUY signal."""
# Price drops from 100 to 90 (10% drop) - should trigger 5% threshold
klines = [
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
{"time": 2000, "open": 100, "high": 101, "low": 89, "close": 90, "volume": 1200}, # 10% drop
]
engine = create_engine(klines_data=klines)
engine.running = True
results = await engine.run()
assert results["total_signals"] >= 1
buy_signals = [s for s in engine.signals if s["signal_type"] == "buy"]
assert len(buy_signals) >= 1
assert buy_signals[0]["price"] == 90.0
@pytest.mark.asyncio
async def test_price_drop_below_threshold_no_signal(self):
"""Price drop < threshold should NOT trigger signal."""
# Price drops from 100 to 98 (2% drop) - below 5% threshold
klines = [
{"time": 1000, "open": 100, "high": 101, "low": 99, "close": 100, "volume": 1000},
{"time": 2000, "open": 100, "high": 101, "low": 97, "close": 98, "volume": 1000}, # 2% drop
]
engine = create_engine(klines_data=klines)
engine.running = True
results = await engine.run()
assert results["total_signals"] == 0
# ==================== Risk Management Tests ====================
@pytest.mark.asyncio
async def test_stop_loss_triggers_after_buy(self):
"""Stop loss should trigger SELL after price drops below threshold."""
klines = [
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
{"time": 2000, "open": 100, "high": 101, "low": 89, "close": 90, "volume": 1200}, # BUY triggered @ 90
{"time": 3000, "open": 90, "high": 91, "low": 84, "close": 85, "volume": 1300}, # Stop loss @ 85.5 (90 * 0.95)
]
engine = create_engine(klines_data=klines)
engine.running = True
results = await engine.run()
buy_signals = [s for s in engine.signals if s["signal_type"] == "buy"]
sell_signals = [s for s in engine.signals if s["signal_type"] == "sell"]
assert len(buy_signals) >= 1, "Should have at least one BUY signal"
assert len(sell_signals) >= 1, "Stop loss should trigger SELL"
assert "stop_loss" in sell_signals[0]["reasoning"]
@pytest.mark.asyncio
async def test_take_profit_triggers_after_buy(self):
"""Take profit should trigger SELL after price rises above threshold."""
klines = [
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
{"time": 2000, "open": 100, "high": 101, "low": 89, "close": 90, "volume": 1200}, # BUY triggered @ 90
{"time": 3000, "open": 90, "high": 101, "low": 89, "close": 100, "volume": 1300}, # TP @ 99 (90 * 1.10)
]
engine = create_engine(klines_data=klines)
engine.running = True
results = await engine.run()
buy_signals = [s for s in engine.signals if s["signal_type"] == "buy"]
sell_signals = [s for s in engine.signals if s["signal_type"] == "sell"]
assert len(buy_signals) >= 1, "Should have at least one BUY signal"
assert len(sell_signals) >= 1, "Take profit should trigger SELL"
assert "take_profit" in sell_signals[0]["reasoning"]
# ==================== Multiple Conditions Tests ====================
@pytest.mark.asyncio
async def test_no_buy_if_already_in_position(self):
"""Should not trigger another BUY if already holding position."""
klines = [
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
{"time": 2000, "open": 100, "high": 101, "low": 89, "close": 90, "volume": 1200}, # BUY triggered
{"time": 3000, "open": 90, "high": 91, "low": 85, "close": 86, "volume": 1300}, # Another drop but already in position
{"time": 4000, "open": 86, "high": 87, "low": 81, "close": 82, "volume": 1400}, # Another drop
]
engine = create_engine(klines_data=klines)
engine.running = True
results = await engine.run()
buy_signals = [s for s in engine.signals if s["signal_type"] == "buy"]
# Should only have 1 buy, not multiple
assert len(buy_signals) == 1, "Should only have one BUY signal"
@pytest.mark.asyncio
async def test_can_buy_again_after_sell(self):
"""Should be able to BUY again after position is closed by risk management."""
klines = [
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
# First trade
{"time": 2000, "open": 100, "high": 101, "low": 89, "close": 90, "volume": 1200}, # BUY @ 90
{"time": 3000, "open": 90, "high": 91, "low": 84, "close": 85, "volume": 1300}, # STOP LOSS @ 85.5
# Second trade
{"time": 4000, "open": 85, "high": 86, "low": 79, "close": 80, "volume": 1400}, # BUY @ 80 (after position closed)
{"time": 5000, "open": 80, "high": 89, "low": 79, "close": 88, "volume": 1500}, # TP @ 88
]
engine = create_engine(klines_data=klines)
engine.running = True
results = await engine.run()
buy_signals = [s for s in engine.signals if s["signal_type"] == "buy"]
sell_signals = [s for s in engine.signals if s["signal_type"] == "sell"]
assert len(buy_signals) == 2, "Should have two BUY signals"
assert len(sell_signals) == 2, "Should have two SELL signals"
# ==================== Edge Cases ====================
@pytest.mark.asyncio
async def test_handles_zero_price(self):
"""Should skip processing for candles with zero price but still count them."""
klines = [
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
{"time": 2000, "open": 0, "high": 0, "low": 0, "close": 0, "volume": 0}, # Skipped in processing
{"time": 3000, "open": 100, "high": 101, "low": 89, "close": 90, "volume": 1200}, # This should work
]
engine = create_engine(klines_data=klines)
engine.running = True
results = await engine.run()
# All 3 candles counted, but only 2 valid for condition checking
assert results["candles_processed"] == 3
# Only 1 signal (the valid candle that dropped 10%)
assert results["total_signals"] == 1
@pytest.mark.asyncio
async def test_max_candles_limit(self):
"""Should respect max_candles limit."""
klines = [
{"time": i * 1000, "open": 100, "high": 101, "low": 99, "close": 100, "volume": 1000}
for i in range(1, 201) # 200 candles
]
engine = create_engine(klines_data=klines, config_override={"max_candles": 50})
engine.running = True
results = await engine.run()
assert results["candles_processed"] == 50
@pytest.mark.asyncio
async def test_stop_interrupts_processing(self):
"""Should stop processing when stop() is called."""
klines = [
{"time": i * 1000, "open": 100, "high": 101, "low": 99, "close": 100, "volume": 1000}
for i in range(1, 101)
]
engine = create_engine(klines_data=klines)
engine.running = True
engine.run_id = "test"
# Stop after a few candles
async def stop_after_delay():
await asyncio.sleep(0.1)
engine.stop()
await asyncio.gather(engine.run(), stop_after_delay())
assert engine.status == "stopped"
# Should have processed some candles before stopping
assert engine.last_processed_time is not None
# ==================== Price Movement Display Tests ====================
@pytest.mark.asyncio
async def test_records_all_processed_prices(self):
"""Should track last processed time for display purposes."""
klines = [
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
{"time": 2000, "open": 100, "high": 101, "low": 99, "close": 101, "volume": 1100},
{"time": 3000, "open": 101, "high": 103, "low": 100, "close": 102, "volume": 1200},
]
engine = create_engine(klines_data=klines)
engine.running = True
await engine.run()
# Should have tracked the last candle's time
assert engine.last_processed_time == 3000
@pytest.mark.asyncio
async def test_tracks_price_changes(self):
"""Should track price changes for potential chart display."""
klines = [
{"time": 1000, "open": 100, "high": 102, "low": 99, "close": 100, "volume": 1000},
{"time": 2000, "open": 100, "high": 105, "low": 99, "close": 104, "volume": 1100},
]
engine = create_engine(klines_data=klines)
engine.running = True
await engine.run()
# Last close should be the last candle's close
assert engine.last_close == 104.0
# ==================== Integration Tests ====================
@pytest.mark.asyncio
async def test_full_simulation_workflow_generates_signals_and_trades(self):
"""
Full integration test: provides klines with clear price movements
and verifies signals and trade_log are populated.
This test ensures the simulation is working by:
1. Creating klines with obvious price movements (drops > 0.1%)
2. Using a very low threshold (0.1%)
3. Verifying signals are generated
4. Verifying trade_log is populated
5. Verifying we have buy/sell actions
"""
# Create klines with clear price drops and rises
klines = [
{"time": 1000, "open": 100, "high": 101, "low": 99, "close": 100, "volume": 1000}, # Flat
{"time": 2000, "open": 100, "high": 101, "low": 99.9, "close": 99.95, "volume": 1000}, # 0.05% drop
{"time": 3000, "open": 99.95, "high": 100, "low": 99.5, "close": 99.5, "volume": 1000}, # 0.45% drop
{"time": 4000, "open": 99.5, "high": 100, "low": 99, "close": 99.2, "volume": 1000}, # 0.30% drop
{"time": 5000, "open": 99.2, "high": 100, "low": 98, "close": 98.5, "volume": 1000}, # 0.71% drop
{"time": 6000, "open": 98.5, "high": 99, "low": 98, "close": 98.8, "volume": 1000}, # 0.30% rise
{"time": 7000, "open": 98.8, "high": 99, "low": 98, "close": 98.3, "volume": 1000}, # 0.51% drop
{"time": 8000, "open": 98.3, "high": 99, "low": 97, "close": 97.5, "volume": 1000}, # 0.81% drop
{"time": 9000, "open": 97.5, "high": 98, "low": 96, "close": 96.5, "volume": 1000}, # 1.03% drop
]
# Use very low threshold to ensure signals are generated
config_override = {
"max_candles": 100,
"strategy_config": {
"conditions": [
{"type": "price_drop", "threshold": 0.1, "token": "TEST", "token_address": "0x1234"}
],
"actions": [
{"type": "buy", "amount_percent": 10}
],
"risk_management": {
"stop_loss_percent": 5,
"take_profit_percent": 5
}
}
}
engine = create_engine(config_override=config_override, klines_data=klines)
engine.running = True
engine.run_id = "integration-test"
results = await engine.run()
# Verify results
print(f"\n=== Integration Test Results ===")
print(f"Status: {engine.status}")
print(f"Candles processed: {results.get('candles_processed')}")
print(f"Signals count: {len(engine.signals)}")
print(f"Trade log count: {len(engine.trade_log)}")
# ASSERTIONS - These should NEVER fail if simulation is working
assert engine.status == "completed", "Simulation should complete successfully"
assert results.get("candles_processed") == len(klines), f"Should process all {len(klines)} candles"
# Critical: signals should NOT be empty
assert len(engine.signals) > 0, "SIGNALS SHOULD NOT BE EMPTY! Simulation is not generating signals."
print(f"Signals: {[s['signal_type'] for s in engine.signals]}")
# Critical: trade_log should NOT be empty
assert len(engine.trade_log) > 0, "TRADE_LOG SHOULD NOT BE EMPTY! No activity logged."
print(f"Trade log: {[t['action'] for t in engine.trade_log]}")
# Should have at least one BUY signal
buy_signals = [s for s in engine.signals if s['signal_type'] == 'buy']
assert len(buy_signals) > 0, "Should have at least one BUY signal"
print(f"Buy signals: {len(buy_signals)}")
# Verify trade_log has BUY action
buy_trades = [t for t in engine.trade_log if t['action'] == 'buy']
assert len(buy_trades) > 0, "Trade log should contain BUY actions"
# Verify results contain the data
assert "signals" in results, "Results should contain signals"
assert "trade_log" in results, "Results should contain trade_log"
print("\n=== Integration Test PASSED ===")
print(f"Simulation working correctly!")
print(f"Generated {len(engine.signals)} signals and {len(engine.trade_log)} trade log entries")
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@@ -0,0 +1,2 @@
VITE_API_URL=http://localhost:8000/api
VITE_WS_URL=ws://localhost:8000/ws

23
src/frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
src/frontend/.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

3
src/frontend/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}

42
src/frontend/README.md Normal file
View File

@@ -0,0 +1,42 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project
npx sv create my-app
```
To recreate this project with the same configuration:
```sh
# recreate this project
npx sv@0.15.0 create --template minimal --types ts --no-install .
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

1404
src/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
src/frontend/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "frontend",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/kit": "^2.57.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"svelte": "^5.55.2",
"svelte-check": "^4.4.6",
"typescript": "^6.0.2",
"vite": "^8.0.7"
},
"dependencies": {
"chart.js": "^4.5.1"
}
}

13
src/frontend/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

12
src/frontend/src/app.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1,241 @@
import type {
User,
Bot,
BotConversation,
Backtest,
Simulation,
Signal,
AuthResponse,
BotChatRequest,
BotChatResponse,
StrategyConfig
} from './types';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000/api';
function getAuthHeaders(): HeadersInit {
const token = localStorage.getItem('token');
return token ? { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } : { 'Content-Type': 'application/json' };
}
async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'An error occurred' }));
let errorMessage = 'An error occurred';
if (typeof error.detail === 'string') {
errorMessage = error.detail;
} else if (Array.isArray(error.detail)) {
// Handle FastAPI validation error format: [{type, loc, msg, input}]
errorMessage = error.detail.map((e: any) => e.msg || JSON.stringify(e)).join(', ');
} else if (error.message) {
errorMessage = error.message;
} else {
errorMessage = `HTTP error ${response.status}`;
}
throw new Error(errorMessage);
}
return response.json();
}
export const api = {
auth: {
async register(email: string, password: string): Promise<AuthResponse> {
const response = await fetch(`${API_URL}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
return handleResponse<AuthResponse>(response);
},
async login(email: string, password: string): Promise<AuthResponse> {
const response = await fetch(`${API_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: email, password })
});
return handleResponse<AuthResponse>(response);
},
async logout(): Promise<void> {
await fetch(`${API_URL}/auth/logout`, {
method: 'POST',
headers: getAuthHeaders()
});
},
async me(): Promise<User> {
const response = await fetch(`${API_URL}/auth/me`, {
headers: getAuthHeaders()
});
return handleResponse<User>(response);
}
},
bots: {
async list(): Promise<Bot[]> {
const response = await fetch(`${API_URL}/bots`, {
headers: getAuthHeaders()
});
return handleResponse<Bot[]>(response);
},
async create(name: string, description?: string): Promise<Bot> {
const response = await fetch(`${API_URL}/bots`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ name, description })
});
return handleResponse<Bot>(response);
},
async get(id: string): Promise<Bot> {
const response = await fetch(`${API_URL}/bots/${id}`, {
headers: getAuthHeaders()
});
return handleResponse<Bot>(response);
},
async update(id: string, data: Partial<Bot>): Promise<Bot> {
const response = await fetch(`${API_URL}/bots/${id}`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(data)
});
return handleResponse<Bot>(response);
},
async delete(id: string): Promise<void> {
const response = await fetch(`${API_URL}/bots/${id}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
if (!response.ok) {
throw new Error(`HTTP error ${response.status}`);
}
},
async chat(id: string, message: string, signal?: AbortSignal): Promise<BotChatResponse> {
const response = await fetch(`${API_URL}/bots/${id}/chat`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ message } as BotChatRequest),
signal
});
return handleResponse<BotChatResponse>(response);
},
async getHistory(id: string): Promise<BotConversation[]> {
const response = await fetch(`${API_URL}/bots/${id}/history`, {
headers: getAuthHeaders()
});
return handleResponse<BotConversation[]>(response);
}
},
backtest: {
async start(botId: string, config: { token: string; timeframe: string; start_date: string; end_date: string }): Promise<Backtest> {
const response = await fetch(`${API_URL}/bots/${botId}/backtest`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ ...config, chain: 'bsc' })
});
return handleResponse<Backtest>(response);
},
async get(botId: string, runId: string): Promise<Backtest> {
const response = await fetch(`${API_URL}/bots/${botId}/backtest/${runId}`, {
headers: getAuthHeaders()
});
return handleResponse<Backtest>(response);
},
async list(botId: string): Promise<Backtest[]> {
const response = await fetch(`${API_URL}/bots/${botId}/backtests`, {
headers: getAuthHeaders()
});
return handleResponse<Backtest[]>(response);
},
async stop(botId: string, runId: string): Promise<void> {
const response = await fetch(`${API_URL}/bots/${botId}/backtest/${runId}/stop`, {
method: 'POST',
headers: getAuthHeaders()
});
if (!response.ok) {
throw new Error(`HTTP error ${response.status}`);
}
},
async getTrades(botId: string, runId: string, page: number = 1, perPage: number = 5): Promise<{
trades: any[];
total_trades: number;
page: number;
per_page: number;
total_pages: number;
has_next: boolean;
has_prev: boolean;
}> {
const response = await fetch(`${API_URL}/bots/${botId}/backtest/${runId}/trades?page=${page}&per_page=${perPage}`, {
headers: getAuthHeaders()
});
if (!response.ok) {
throw new Error(`HTTP error ${response.status}`);
}
return response.json();
}
},
simulate: {
async start(botId: string, config: { token: string; chain?: string; kline_interval: string }): Promise<Simulation> {
const response = await fetch(`${API_URL}/bots/${botId}/simulate`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(config)
});
return handleResponse<Simulation>(response);
},
async get(botId: string, runId: string): Promise<Simulation> {
const response = await fetch(`${API_URL}/bots/${botId}/simulate/${runId}`, {
headers: getAuthHeaders()
});
return handleResponse<Simulation>(response);
},
async list(botId: string): Promise<Simulation[]> {
const response = await fetch(`${API_URL}/bots/${botId}/simulations`, {
headers: getAuthHeaders()
});
return handleResponse<Simulation[]>(response);
},
async stop(botId: string, runId: string): Promise<void> {
const response = await fetch(`${API_URL}/bots/${botId}/simulate/${runId}/stop`, {
method: 'POST',
headers: getAuthHeaders()
});
if (!response.ok) {
throw new Error(`HTTP error ${response.status}`);
}
}
},
config: {
async getChains(): Promise<string[]> {
const response = await fetch(`${API_URL}/config/chains`, {
headers: getAuthHeaders()
});
return handleResponse<string[]>(response);
},
async getTokens(): Promise<{ symbol: string; chain: string; name: string }[]> {
const response = await fetch(`${API_URL}/config/tokens`, {
headers: getAuthHeaders()
});
return handleResponse<{ symbol: string; chain: string; name: string }[]>(response);
}
}
};

View File

@@ -0,0 +1,2 @@
export { api } from './client';
export * from './types';

View File

@@ -0,0 +1,188 @@
export interface User {
id: string;
email: string;
created_at: string;
updated_at: string;
}
export interface Bot {
id: string;
user_id: string;
name: string;
description: string | null;
strategy_config: StrategyConfig;
llm_config: LLMConfig;
status: 'draft' | 'active' | 'paused';
created_at: string;
updated_at: string;
}
export interface StrategyConfig {
conditions: Condition[];
actions: Action[];
risk_management?: RiskManagement;
}
export interface Condition {
type: 'price_drop' | 'price_rise' | 'volume_spike' | 'price_level';
token: string;
token_address?: string;
chain?: string;
threshold?: number;
price?: number;
direction?: 'above' | 'below';
timeframe?: string;
}
export interface Action {
type: 'buy' | 'sell' | 'hold';
amount_percent?: number;
token?: string;
token_address?: string;
}
export interface RiskManagement {
stop_loss_percent?: number;
take_profit_percent?: number;
}
export interface LLMConfig {
model: string;
temperature: number;
}
export interface BotConversation {
id: string;
bot_id: string;
role: 'user' | 'assistant' | 'system';
content: string;
created_at: string;
}
export interface Backtest {
id: string;
bot_id: string;
started_at: string;
ended_at: string | null;
status: 'running' | 'completed' | 'failed' | 'stopped';
config: BacktestConfig;
result: BacktestResult | null;
progress?: number;
}
export interface BacktestConfig {
token: string;
token_name?: string;
chain: string;
timeframe: string;
start_date: string;
end_date: string;
}
export interface BacktestResult {
total_return: number;
win_rate: number;
total_trades: number;
buy_signals: number;
sell_signals: number;
max_drawdown: number;
sharpe_ratio: number;
}
export interface PaginatedTrades {
trades: Trade[];
total_trades: number;
page: number;
per_page: number;
total_pages: number;
has_next: boolean;
has_prev: boolean;
}
export interface Trade {
type: 'buy' | 'sell';
token: string;
price: number;
amount: number;
quantity: number;
timestamp: number;
exit_reason?: 'stop_loss' | 'take_profit' | string;
}
export interface Simulation {
id: string;
bot_id: string;
started_at: string;
status: 'running' | 'stopped' | 'completed';
config: SimulationConfig;
signals: Signal[] | null;
klines?: { time: number; close: number }[];
trade_log?: TradeLogEntry[];
portfolio?: Portfolio;
current_candle_index?: number;
total_candles?: number;
candles_processed?: number;
}
export interface SimulationConfig {
token: string;
chain?: string;
kline_interval?: string;
}
export interface TradeLogEntry {
time: number;
price: number;
action: 'buy' | 'sell' | 'hold';
reason: string;
position: number;
entry_price: number | null;
}
export interface Portfolio {
initial_balance: number;
current_balance: number;
position: number;
position_token: string;
entry_price: number;
current_price: number;
}
export interface Signal {
id: string;
bot_id: string;
run_id: string;
signal_type: 'buy' | 'sell' | 'hold';
token: string;
price: number;
confidence: number | null;
reasoning: string | null;
executed: boolean;
created_at: string;
}
export interface AuthResponse {
access_token: string;
token_type: string;
}
export interface BotChatRequest {
message: string;
}
export interface BotChatResponse {
response: string;
thinking: string | null;
strategy_config: StrategyConfig | null;
success: boolean;
strategy_needs_confirmation?: boolean;
strategy_data?: StrategyConfig | null;
token_search_results?: TokenSearchResult[] | null;
}
export interface TokenSearchResult {
symbol: string;
name: string;
address: string;
chain: string;
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,313 @@
<script lang="ts">
import type { BacktestResult } from '$lib/api';
interface ChartDataPoint {
timestamp: string;
value: number;
}
interface Props {
results: BacktestResult | null;
signals?: Array<{ created_at: string; signal_type: string; price: number }>;
height?: number;
}
let { results, signals = [], height = 300 }: Props = $props();
let width = $state(800);
let containerEl: HTMLDivElement;
$effect(() => {
if (containerEl) {
width = containerEl.clientWidth;
}
});
function generatePortfolioCurve(): ChartDataPoint[] {
if (!results || signals.length === 0) return [];
const points: ChartDataPoint[] = [];
const startValue = 10000;
let currentValue = startValue;
const sortedSignals = [...signals].sort(
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
);
points.push({
timestamp: sortedSignals[0]?.created_at || new Date().toISOString(),
value: currentValue
});
for (const signal of sortedSignals) {
if (signal.signal_type === 'buy') {
currentValue *= 1.05;
} else if (signal.signal_type === 'sell') {
currentValue *= 0.95;
}
points.push({
timestamp: signal.created_at,
value: currentValue
});
}
return points;
}
function getChartArea(w: number, h: number): { x: number; y: number; width: number; height: number } {
const padding = { top: 20, right: 20, bottom: 40, left: 60 };
return {
x: padding.left,
y: padding.top,
width: w - padding.left - padding.right,
height: h - padding.top - padding.bottom
};
}
function getValueRange(pts: ChartDataPoint[]): { min: number; max: number } {
if (pts.length === 0) return { min: 0, max: 10000 };
const values = pts.map(p => p.value);
const min = Math.min(...values);
const max = Math.max(...values);
const padding = (max - min) * 0.1 || 1000;
return { min: min - padding, max: max + padding };
}
function getPointPosition(point: ChartDataPoint, index: number, total: number, area: { x: number; y: number; width: number; height: number }, range: { min: number; max: number }): { x: number; y: number } {
const x = area.x + (index / Math.max(total - 1, 1)) * area.width;
const normalizedValue = (point.value - range.min) / (range.max - range.min);
const y = area.y + area.height - normalizedValue * area.height;
return { x, y };
}
function getYAxisLabels(area: { x: number; y: number; width: number; height: number }, range: { min: number; max: number }): Array<{ value: number; y: number }> {
const step = (range.max - range.min) / 4;
return [0, 1, 2, 3, 4].map(i => ({
value: range.max - i * step,
y: area.y + (i / 4) * area.height
}));
}
function getXAxisLabels(pts: ChartDataPoint[], area: { x: number; y: number; width: number; height: number }, range: { min: number; max: number }): Array<{ label: string; x: number }> {
if (pts.length === 0) return [];
const step = Math.max(1, Math.floor(pts.length / 5));
return pts
.filter((_, i) => i % step === 0 || i === pts.length - 1)
.map((p, i, arr) => ({
label: new Date(p.timestamp).toLocaleDateString(),
x: getPointPosition(p, pts.indexOf(p), pts.length, area, range).x
}));
}
function getReturnColor(): string {
if (!results) return '#888';
return results.total_return >= 0 ? '#22c55e' : '#ef4444';
}
let points = $derived(generatePortfolioCurve());
let area = $derived(getChartArea(width, height));
let range = $derived(getValueRange(points));
let yAxisLabels = $derived(getYAxisLabels(area, range));
let xAxisLabels = $derived(getXAxisLabels(points, area, range));
</script>
<div class="backtest-chart" bind:this={containerEl}>
{#if !results}
<div class="empty-state">
<p>No backtest results to display</p>
</div>
{:else}
<div class="chart-header">
<div class="metric">
<span class="metric-label">Total Return</span>
<span class="metric-value" style="color: {getReturnColor()}">
{results.total_return >= 0 ? '+' : ''}{results.total_return.toFixed(2)}%
</span>
</div>
<div class="metric">
<span class="metric-label">Win Rate</span>
<span class="metric-value">{results.win_rate.toFixed(1)}%</span>
</div>
<div class="metric">
<span class="metric-label">Total Trades</span>
<span class="metric-value">{results.total_trades}</span>
</div>
<div class="metric">
<span class="metric-label">Sharpe Ratio</span>
<span class="metric-value">{results.sharpe_ratio.toFixed(2)}</span>
</div>
</div>
<svg {width} {height} viewBox="0 0 {width} {height}">
<defs>
<linearGradient id="portfolioGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="rgba(102, 126, 234, 0.4)" />
<stop offset="100%" stop-color="rgba(102, 126, 234, 0)" />
</linearGradient>
</defs>
<g class="grid-lines">
{#each [0, 1, 2, 3, 4] as i}
{@const y = area.y + (i / 4) * area.height}
<line
x1={area.x}
y1={y}
x2={area.x + area.width}
y2={y}
stroke="rgba(255,255,255,0.08)"
stroke-dasharray="4,4"
/>
{/each}
</g>
<g class="y-axis">
{#each yAxisLabels as label}
<text x={area.x - 8} y={label.y + 4} class="axis-label" text-anchor="end">
${label.value.toLocaleString()}
</text>
{/each}
</g>
<g class="x-axis">
{#each xAxisLabels as label}
<text x={label.x} y={height - 8} class="axis-label" text-anchor="middle">
{label.label}
</text>
{/each}
</g>
{#if points.length > 1}
<path
d={points.map((p, i) => {
const pos = getPointPosition(p, i, points.length, area, range);
if (i === 0) {
return `M ${pos.x} ${area.y + area.height} L ${pos.x} ${pos.y}`;
}
return `L ${pos.x} ${pos.y}`;
}).join(' ') + ` L ${getPointPosition(points[points.length - 1], points.length - 1, points.length, area, range).x} ${area.y + area.height} Z`}
fill="url(#portfolioGradient)"
/>
<path
d={points.map((p, i) => {
const pos = getPointPosition(p, i, points.length, area, range);
return `${i === 0 ? 'M' : 'L'} ${pos.x} ${pos.y}`;
}).join(' ')}
fill="none"
stroke="#667eea"
stroke-width="2.5"
/>
{/if}
</svg>
<div class="chart-footer">
<div class="stat">
<span class="stat-label">Buy Signals</span>
<span class="stat-value buy">{results.buy_signals}</span>
</div>
<div class="stat">
<span class="stat-label">Sell Signals</span>
<span class="stat-value sell">{results.sell_signals}</span>
</div>
<div class="stat">
<span class="stat-label">Max Drawdown</span>
<span class="stat-value negative">-{results.max_drawdown.toFixed(2)}%</span>
</div>
</div>
{/if}
</div>
<style>
.backtest-chart {
width: 100%;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 1rem;
box-sizing: border-box;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
height: 300px;
color: #666;
}
.chart-header {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.metric {
display: flex;
flex-direction: column;
gap: 0.25rem;
text-align: center;
}
.metric-label {
font-size: 0.75rem;
color: #888;
text-transform: uppercase;
}
.metric-value {
font-size: 1.25rem;
font-weight: 600;
}
svg {
display: block;
width: 100%;
height: auto;
}
.axis-label {
font-size: 10px;
fill: #666;
}
.chart-footer {
display: flex;
justify-content: center;
gap: 2rem;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
.stat-label {
font-size: 0.75rem;
color: #888;
}
.stat-value {
font-size: 1rem;
font-weight: 500;
}
.buy {
color: #22c55e;
}
.sell {
color: #ef4444;
}
.negative {
color: #ef4444;
}
</style>

View File

@@ -0,0 +1,127 @@
<script lang="ts">
import type { Bot } from '$lib/api';
interface Props {
bot: Bot;
onOpen?: (botId: string) => void;
onDelete?: (botId: string) => void;
showActions?: boolean;
}
let { bot, onOpen, onDelete, showActions = true }: Props = $props();
function handleOpen() {
onOpen?.(bot.id);
}
function handleDelete(e: Event) {
e.stopPropagation();
onDelete?.(bot.id);
}
</script>
<div class="bot-card" onclick={handleOpen} role="button" tabindex="0" onkeydown={(e) => e.key === 'Enter' && handleOpen()}>
<div class="bot-info">
<h3>{bot.name}</h3>
{#if bot.description}
<p class="bot-description">{bot.description}</p>
{/if}
<span class="bot-status status-{bot.status}">{bot.status}</span>
</div>
{#if showActions}
<div class="bot-actions" onclick={(e) => e.stopPropagation()} role="group">
<button class="btn btn-primary" onclick={handleOpen}>Open</button>
<button class="btn btn-danger" onclick={handleDelete}>Delete</button>
</div>
{/if}
</div>
<style>
.bot-card {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 1.5rem;
cursor: pointer;
transition: transform 0.2s, border-color 0.2s;
}
.bot-card:hover {
transform: translateY(-2px);
border-color: rgba(255, 255, 255, 0.2);
}
.bot-card:focus {
outline: 2px solid #667eea;
outline-offset: 2px;
}
.bot-info {
margin-bottom: 1rem;
}
.bot-info h3 {
margin: 0 0 0.5rem;
font-size: 1.25rem;
}
.bot-description {
color: #888;
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.bot-status {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
}
.status-draft {
background: rgba(251, 191, 36, 0.2);
color: #fbbf24;
}
.status-active {
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
}
.status-paused {
background: rgba(251, 191, 36, 0.2);
color: #fbbf24;
}
.bot-actions {
display: flex;
gap: 0.75rem;
}
.btn {
padding: 0.5rem 1rem;
border-radius: 8px;
font-weight: 500;
font-size: 0.9rem;
cursor: pointer;
border: none;
transition: transform 0.2s, opacity 0.2s;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-danger {
background: rgba(239, 68, 68, 0.2);
color: #fca5a5;
border: 1px solid rgba(239, 68, 68, 0.4);
}
.btn:hover {
transform: translateY(-2px);
}
</style>

View File

@@ -0,0 +1,94 @@
<script lang="ts">
import type { Bot } from '$lib/api';
interface Props {
bots: Bot[];
selectedBotId?: string | null;
onSelect: (botId: string) => void;
disabled?: boolean;
label?: string;
}
let { bots, selectedBotId = null, onSelect, disabled = false, label = 'Select Bot' }: Props = $props();
function handleChange(e: Event) {
const target = e.target as HTMLSelectElement;
onSelect(target.value);
}
const MAX_BOTS = 3;
</script>
<div class="bot-selector">
{#if label}
<label for="bot-select">{label}</label>
{/if}
<div class="select-wrapper">
<select
id="bot-select"
onchange={handleChange}
disabled={disabled || bots.length === 0}
value={selectedBotId || ''}
>
{#if bots.length === 0}
<option value="" disabled>No bots available</option>
{:else}
{#each bots as bot}
<option value={bot.id}>{bot.name}</option>
{/each}
{/if}
</select>
<span class="bot-count">{bots.length}/{MAX_BOTS}</span>
</div>
</div>
<style>
.bot-selector {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
label {
font-size: 0.9rem;
color: #888;
}
.select-wrapper {
display: flex;
align-items: center;
gap: 0.75rem;
}
select {
flex: 1;
padding: 0.75rem 1rem;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.05);
color: #fff;
font-size: 1rem;
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23888' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 1rem center;
padding-right: 2.5rem;
}
select:focus {
outline: none;
border-color: #667eea;
}
select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.bot-count {
font-size: 0.8rem;
color: #666;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,744 @@
<script lang="ts">
import type { Bot } from '$lib/api';
import type { ChatMessage } from '$lib/stores/chatStore';
import { parseMarkdown, parseInlineElements, type InlineSegment } from '$lib/utils/markdown';
interface ToolItem {
name: string;
description: string;
command: string;
}
const TOOLS: { category: string; label: string; tools: ToolItem[] }[] = [
{
category: 'randebu',
label: '🤖 Randebu Built-in',
tools: [
{ name: 'backtest', description: 'Run strategy backtest', command: '/backtest' },
{ name: 'simulate', description: 'Start/stop simulation', command: '/simulate' },
{ name: 'strategy', description: 'View/update strategy', command: '/strategy' },
]
},
{
category: 'ave',
label: '☁️ AVE Cloud Skills',
tools: [
{ name: 'search', description: 'Token search', command: '/search' },
{ name: 'trending', description: 'Popular tokens', command: '/trending' },
{ name: 'risk', description: 'Honeypot detection', command: '/risk' },
{ name: 'token', description: 'Token details', command: '/token' },
{ name: 'price', description: 'Batch prices', command: '/price' },
]
}
];
interface Props {
bot: Bot | null;
messages: ChatMessage[];
isSending?: boolean;
onSendMessage: (message: string) => void;
onSelectBot?: (botId: string) => void;
availableBots?: Bot[];
showBotSelector?: boolean;
}
let {
bot,
messages,
isSending = false,
onSendMessage,
onSelectBot,
availableBots = [],
showBotSelector = false
}: Props = $props();
let messageInput = $state('');
let chatContainer: HTMLDivElement;
let expandedThinking: Record<string, boolean> = $state({});
let showSlashMenu = $state(false);
let slashMenuPosition = $state({ top: 0, left: 0 });
let selectedIndex = $state(0);
// Use $derived for filteredTools
let filteredTools = $derived(messageInput.startsWith('/') ? TOOLS.flatMap(t => t.tools).filter(tool => tool.name.toLowerCase().startsWith(messageInput.slice(1).toLowerCase()) || tool.command.toLowerCase().startsWith(messageInput.slice(1).toLowerCase())) : []);
function handleSend() {
if (!messageInput.trim()) return;
showSlashMenu = false;
onSendMessage(messageInput);
messageInput = '';
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (showSlashMenu && filteredTools.length > 0) {
selectTool(filteredTools[selectedIndex]);
} else {
handleSend();
}
} else if (e.key === 'ArrowDown' && showSlashMenu) {
e.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, filteredTools.length - 1);
} else if (e.key === 'ArrowUp' && showSlashMenu) {
e.preventDefault();
selectedIndex = Math.max(selectedIndex - 1, 0);
} else if (e.key === 'Escape' && showSlashMenu) {
showSlashMenu = false;
} else if (e.key === 'Tab' && showSlashMenu && filteredTools.length > 0) {
e.preventDefault();
selectTool(filteredTools[selectedIndex]);
}
}
function handleInput(e: Event) {
const target = e.target as HTMLTextAreaElement;
const value = target.value;
messageInput = value;
if (value.startsWith('/')) {
selectedIndex = 0;
showSlashMenu = filteredTools.length > 0;
if (showSlashMenu) {
// Position menu above the textarea
const rect = target.getBoundingClientRect();
const menuHeight = 300;
slashMenuPosition = {
top: Math.max(10, rect.top - menuHeight),
left: rect.left
};
}
} else {
showSlashMenu = false;
}
}
function selectTool(tool: ToolItem) {
messageInput = tool.command + ' ';
showSlashMenu = false;
const textarea = document.querySelector('.input-container textarea') as HTMLTextAreaElement;
if (textarea) {
textarea.focus();
}
}
function handleBotChange(e: Event) {
const target = e.target as HTMLSelectElement;
if (onSelectBot && target.value) {
onSelectBot(target.value);
}
}
function toggleThinkingExpand(messageId: string) {
expandedThinking[messageId] = !expandedThinking[messageId];
}
$effect(() => {
if (messages.length && chatContainer) {
setTimeout(() => {
chatContainer.scrollTop = chatContainer.scrollHeight;
}, 50);
}
});
function renderContent(content: string) {
return parseMarkdown(content);
}
function renderInline(segments: InlineSegment[]): string {
return segments.map(seg => {
switch (seg.type) {
case 'bold': return `<strong>${seg.content}</strong>`;
case 'italic': return `<em>${seg.content}</em>`;
case 'code': return `<code class="inline-code">${seg.content}</code>`;
case 'link': return `<a href="${seg.href || '#'}" target="_blank" rel="noopener noreferrer">${seg.content}</a>`;
default: return seg.content;
}
}).join('');
}
function handleClickOutside(e: MouseEvent) {
const target = e.target as HTMLElement;
if (!target.closest('.slash-menu') && !target.closest('.input-container textarea')) {
showSlashMenu = false;
}
}
</script>
<svelte:window on:click={handleClickOutside} />
<div class="chat-interface">
{#if showBotSelector && availableBots.length > 0}
<div class="bot-selector">
<label for="bot-select">Active Bot:</label>
<select id="bot-select" onchange={handleBotChange}>
{#each availableBots as availableBot}
<option value={availableBot.id} selected={availableBot.id === bot?.id}>
{availableBot.name}
</option>
{/each}
</select>
</div>
{/if}
<div class="chat-messages" bind:this={chatContainer}>
{#if messages.length === 0}
<div class="welcome-message">
<p>Welcome to {bot?.name || 'your bot'}! Describe your trading strategy in plain English.</p>
<p class="hint">Example: "Buy PEPE when the price drops by 5% within 1 hour"</p>
</div>
{/if}
{#each messages as message}
<div class="message {message.role}">
{#if message.role === 'assistant' && message.thinking}
{@const firstLine = message.thinking.split('\n')[0]}
{@const isExpanded = expandedThinking[message.id] ?? false}
<div class="thinking-section">
<button class="thinking-toggle" onclick={() => toggleThinkingExpand(message.id)}>
<span class="thinking-icon">{isExpanded ? '▼' : '▶'}</span>
<span class="thinking-label">{isExpanded ? 'Hide reasoning' : 'Show reasoning'}</span>
{#if !isExpanded}
<span class="thinking-preview">{firstLine.slice(0, 60)}{firstLine.length > 60 ? '...' : ''}</span>
{/if}
</button>
{#if isExpanded}
<div class="thinking-content">
{message.thinking}
</div>
{/if}
</div>
{/if}
<div class="message-content">
{#each renderContent(message.content) as segment}
{#if segment.type === 'bold'}
<strong>{segment.content}</strong>
{:else if segment.type === 'italic'}
<em>{segment.content}</em>
{:else if segment.type === 'code'}
<code class="inline-code">{segment.content}</code>
{:else if segment.type === 'codeBlock'}
<pre class="code-block"><code>{segment.content}</code></pre>
{:else if segment.type === 'link'}
<a href={segment.content} target="_blank" rel="noopener noreferrer">{segment.content}</a>
{:else if segment.type === 'list' && segment.items}
<ul>
{#each segment.items as item}
<li>{@html renderInline(parseInlineElements(item))}</li>
{/each}
</ul>
{:else if segment.type === 'table' && segment.headers && segment.rows}
<div class="table-wrapper">
<table class="markdown-table">
<thead>
<tr>
{#each segment.headers as header}
<th>
{#each header as cellSeg}
{#if cellSeg.type === 'bold'}
<strong>{cellSeg.content}</strong>
{:else if cellSeg.type === 'italic'}
<em>{cellSeg.content}</em>
{:else if cellSeg.type === 'code'}
<code class="inline-code">{cellSeg.content}</code>
{:else if cellSeg.type === 'link'}
<a href={cellSeg.href} target="_blank" rel="noopener noreferrer">{cellSeg.content}</a>
{:else}
{cellSeg.content}
{/if}
{/each}
</th>
{/each}
</tr>
</thead>
<tbody>
{#each segment.rows as row}
<tr>
{#each row as cell}
<td>
{#each cell as cellSeg}
{#if cellSeg.type === 'bold'}
<strong>{cellSeg.content}</strong>
{:else if cellSeg.type === 'italic'}
<em>{cellSeg.content}</em>
{:else if cellSeg.type === 'code'}
<code class="inline-code">{cellSeg.content}</code>
{:else if cellSeg.type === 'link'}
<a href={cellSeg.href} target="_blank" rel="noopener noreferrer">{cellSeg.content}</a>
{:else}
{cellSeg.content}
{/if}
{/each}
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
{:else if segment.type === 'heading'}
<h4 class="content-heading">{segment.content}</h4>
{:else if segment.type === 'lineBreak'}
<br />
{:else}
{segment.content}
{/if}
{/each}
</div>
<div class="message-time">
{message.timestamp.toLocaleTimeString()}
</div>
</div>
{/each}
{#if isSending}
<div class="message assistant">
<div class="message-content">
<div class="typing">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</div>
</div>
</div>
{/if}
</div>
{#if bot}
<div class="input-container">
{#if showSlashMenu && filteredTools.length > 0}
<div class="slash-menu" style="top: {slashMenuPosition.top}px; left: {slashMenuPosition.left}px;">
<div class="slash-menu-header">Available Commands</div>
{#each TOOLS as group}
{#if group.tools.some(t => filteredTools.includes(t))}
<div class="slash-menu-category">{group.label}</div>
{#each group.tools.filter(t => filteredTools.includes(t)) as tool, i}
<button
class="slash-menu-item"
class:selected={filteredTools.indexOf(tool) === selectedIndex}
onclick={() => selectTool(tool)}
>
<span class="slash-command">{tool.command}</span>
<span class="slash-description">{tool.description}</span>
</button>
{/each}
{/if}
{/each}
<div class="slash-menu-hint">Press Tab to select, Enter to send</div>
</div>
{/if}
<textarea
value={messageInput}
oninput={handleInput}
onkeydown={handleKeydown}
placeholder="Describe your trading strategy... (or type / for commands)"
rows="1"
></textarea>
<button onclick={handleSend}>
Send
</button>
</div>
{/if}
</div>
<style>
.chat-interface {
display: flex;
flex-direction: column;
height: 100%;
}
.bot-selector {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: rgba(255, 255, 255, 0.03);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.bot-selector label {
font-size: 0.9rem;
color: #888;
}
.bot-selector select {
padding: 0.5rem 0.75rem;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.05);
color: #fff;
font-size: 0.9rem;
cursor: pointer;
}
.bot-selector select:focus {
outline: none;
border-color: #667eea;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
.welcome-message {
text-align: center;
padding: 2rem;
color: #888;
}
.welcome-message .hint {
font-size: 0.85rem;
margin-top: 1rem;
color: #666;
}
.message {
display: flex;
flex-direction: column;
margin-bottom: 1rem;
}
.message.user {
align-items: flex-end;
}
.message.assistant {
align-items: flex-start;
}
.message.system {
align-items: center;
}
.message-content {
max-width: 70%;
padding: 0.75rem 1rem;
border-radius: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.message.user .message-content {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-bottom-right-radius: 4px;
}
.message.assistant .message-content {
background: rgba(255, 255, 255, 0.1);
border-bottom-left-radius: 4px;
}
.thinking-section {
margin-bottom: 0.5rem;
padding: 0.5rem 0.75rem;
background: rgba(255, 255, 255, 0.03);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.thinking-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
background: none;
border: none;
color: #888;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
transition: background 0.2s;
width: 100%;
text-align: left;
}
.thinking-toggle:hover {
background: rgba(255, 255, 255, 0.1);
}
.thinking-icon {
font-size: 0.6rem;
color: #667eea;
}
.thinking-label {
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #667eea;
}
.thinking-preview {
color: #666;
font-style: italic;
font-weight: normal;
text-transform: none;
letter-spacing: normal;
}
.thinking-content {
color: #888;
font-size: 0.85rem;
padding: 0.75rem 0.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
margin-top: 0.5rem;
white-space: pre-wrap;
line-height: 1.6;
}
.message.system .message-content {
background: rgba(251, 191, 36, 0.1);
color: #fbbf24;
font-size: 0.9rem;
border: 1px solid rgba(251, 191, 36, 0.3);
}
.inline-code {
background: rgba(0, 0, 0, 0.3);
padding: 0.15rem 0.4rem;
border-radius: 4px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.85em;
}
.code-block {
background: rgba(0, 0, 0, 0.4);
padding: 0.75rem;
border-radius: 6px;
overflow-x: auto;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.85rem;
margin: 0.5rem 0;
}
.code-block code {
white-space: pre;
}
ul {
margin: 0.5rem 0;
padding-left: 1.5rem;
}
li {
margin: 0.25rem 0;
}
.content-heading {
font-size: 1rem;
font-weight: 600;
margin: 1rem 0 0.5rem;
color: #fff;
}
.content-heading:first-child {
margin-top: 0;
}
.table-wrapper {
overflow-x: auto;
margin: 0.75rem 0;
}
.markdown-table {
border-collapse: collapse;
width: 100%;
font-size: 0.85rem;
background: rgba(0, 0, 0, 0.2);
border-radius: 6px;
overflow: hidden;
}
.markdown-table th,
.markdown-table td {
padding: 0.5rem 0.75rem;
text-align: left;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.markdown-table th {
background: rgba(102, 126, 234, 0.2);
font-weight: 600;
color: #667eea;
}
.markdown-table tr:last-child td {
border-bottom: none;
}
.markdown-table tr:hover td {
background: rgba(255, 255, 255, 0.05);
}
a {
color: #667eea;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.message-time {
font-size: 0.7rem;
color: #666;
margin-top: 0.25rem;
padding: 0 0.5rem;
}
.typing {
display: flex;
gap: 4px;
padding: 0.5rem;
}
.dot {
width: 8px;
height: 8px;
background: #888;
border-radius: 50%;
animation: typing 1.4s infinite;
}
.dot:nth-child(2) {
animation-delay: 0.2s;
}
.dot:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typing {
0%, 60%, 100% {
transform: translateY(0);
opacity: 0.4;
}
30% {
transform: translateY(-4px);
opacity: 1;
}
}
.input-container {
display: flex;
gap: 0.75rem;
padding: 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
textarea {
flex: 1;
padding: 0.75rem 1rem;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.05);
color: #fff;
font-size: 1rem;
resize: none;
font-family: inherit;
}
textarea:focus {
outline: none;
border-color: #667eea;
}
button {
padding: 0.75rem 1.5rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 12px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: transform 0.2s;
}
button:hover:not(:disabled) {
transform: translateY(-2px);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.slash-menu {
position: fixed;
background: rgba(20, 20, 20, 0.98);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 12px;
padding: 0.5rem;
min-width: 280px;
max-width: 400px;
max-height: 300px;
overflow-y: auto;
z-index: 1000;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
.slash-menu-header {
font-size: 0.75rem;
color: #888;
padding: 0.5rem 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
margin-bottom: 0.5rem;
}
.slash-menu-category {
font-size: 0.75rem;
color: #666;
padding: 0.5rem 0.75rem 0.25rem;
}
.slash-menu-item {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
padding: 0.5rem 0.75rem;
background: transparent;
border: none;
border-radius: 8px;
cursor: pointer;
text-align: left;
transition: background 0.15s;
margin: 0.15rem 0;
}
.slash-menu-item:hover,
.slash-menu-item.selected {
background: rgba(102, 126, 234, 0.2);
}
.slash-command {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.9rem;
color: #667eea;
font-weight: 500;
}
.slash-description {
font-size: 0.8rem;
color: #888;
margin-top: 0.15rem;
}
.slash-menu-hint {
font-size: 0.7rem;
color: #555;
padding: 0.5rem 0.75rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
margin-top: 0.5rem;
text-align: center;
}
</style>

View File

@@ -0,0 +1,348 @@
<script lang="ts">
import type { Condition } from '$lib/api';
import TokenPicker from './TokenPicker.svelte';
interface Props {
conditions: Condition[];
onUpdate: (conditions: Condition[]) => void;
disabled?: boolean;
}
let { conditions, onUpdate, disabled = false }: Props = $props();
type ConditionType = Condition['type'];
const conditionTypes: { value: ConditionType; label: string; description: string }[] = [
{ value: 'price_drop', label: 'Price Drop', description: 'Trigger when price falls by X%' },
{ value: 'price_rise', label: 'Price Rise', description: 'Trigger when price rises by X%' },
{ value: 'volume_spike', label: 'Volume Spike', description: 'Trigger when volume increases by X%' },
{ value: 'price_level', label: 'Price Level', description: 'Trigger when price crosses a level' },
];
const timeframes = ['1m', '5m', '15m', '1h', '4h', '1d'];
function addCondition() {
const newCondition: Condition = {
type: 'price_drop',
token: '',
threshold: 5,
timeframe: '1h'
};
onUpdate([...conditions, newCondition]);
}
function removeCondition(index: number) {
onUpdate(conditions.filter((_, i) => i !== index));
}
function updateCondition(index: number, updates: Partial<Condition>) {
const updated = conditions.map((c, i) =>
i === index ? { ...c, ...updates } : c
);
onUpdate(updated);
}
function getConditionDescription(condition: Condition): string {
switch (condition.type) {
case 'price_drop':
return `Price drops ${condition.threshold || 0}% within ${condition.timeframe || '1h'}`;
case 'price_rise':
return `Price rises ${condition.threshold || 0}% within ${condition.timeframe || '1h'}`;
case 'volume_spike':
return `Volume spikes ${condition.threshold || 0}% within ${condition.timeframe || '1h'}`;
case 'price_level':
return `Price crosses ${condition.direction || 'above'} $${condition.price || 0}`;
default:
return 'Unknown condition';
}
}
</script>
<div class="condition-builder">
<div class="conditions-header">
<h4>Conditions</h4>
<button type="button" class="add-btn" onclick={addCondition} {disabled}>
+ Add Condition
</button>
</div>
{#if conditions.length === 0}
<div class="empty-state">
<p>No conditions set</p>
<p class="hint">Add a condition to define when your strategy triggers</p>
</div>
{:else}
<div class="conditions-list">
{#each conditions as condition, index}
<div class="condition-card">
<div class="condition-header">
<span class="condition-number">#{index + 1}</span>
<button
type="button"
class="remove-btn"
onclick={() => removeCondition(index)}
disabled={disabled}
aria-label="Remove condition"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="condition-fields">
<div class="field">
<label for="type-{index}">Type</label>
<select
id="type-{index}"
value={condition.type}
onchange={(e) => updateCondition(index, { type: (e.target as HTMLSelectElement).value as ConditionType })}
disabled={disabled}
>
{#each conditionTypes as ct}
<option value={ct.value}>{ct.label}</option>
{/each}
</select>
</div>
<TokenPicker
label="Token"
selectedToken={condition.token}
selectedChain={condition.chain || ''}
onSelect={(token, chain) => updateCondition(index, { token, chain })}
disabled={disabled}
/>
{#if condition.type === 'price_level'}
<div class="field">
<label for="direction-{index}">Direction</label>
<select
id="direction-{index}"
value={condition.direction || 'above'}
onchange={(e) => updateCondition(index, { direction: (e.target as HTMLSelectElement).value as 'above' | 'below' })}
disabled={disabled}
>
<option value="above">Above</option>
<option value="below">Below</option>
</select>
</div>
<div class="field">
<label for="price-{index}">Price ($)</label>
<input
id="price-{index}"
type="number"
value={condition.price || ''}
oninput={(e) => updateCondition(index, { price: parseFloat((e.target as HTMLInputElement).value) || undefined })}
placeholder="0.000001"
step="any"
min="0"
disabled={disabled}
/>
</div>
{:else}
<div class="field">
<label for="threshold-{index}">Threshold (%)</label>
<input
id="threshold-{index}"
type="number"
value={condition.threshold || ''}
oninput={(e) => updateCondition(index, { threshold: parseFloat((e.target as HTMLInputElement).value) || undefined })}
placeholder="5"
step="any"
min="0"
disabled={disabled}
/>
</div>
<div class="field">
<label for="timeframe-{index}">Timeframe</label>
<select
id="timeframe-{index}"
value={condition.timeframe || '1h'}
onchange={(e) => updateCondition(index, { timeframe: (e.target as HTMLSelectElement).value })}
disabled={disabled}
>
{#each timeframes as tf}
<option value={tf}>{tf}</option>
{/each}
</select>
</div>
{/if}
</div>
<div class="condition-preview">
<span class="preview-label">Summary:</span>
{getConditionDescription(condition)}
</div>
</div>
{/each}
</div>
{/if}
</div>
<style>
.condition-builder {
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 1rem;
}
.conditions-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
h4 {
margin: 0;
font-size: 0.9rem;
color: #888;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.add-btn {
padding: 0.5rem 1rem;
background: rgba(102, 126, 234, 0.2);
color: #667eea;
border: 1px solid rgba(102, 126, 234, 0.4);
border-radius: 6px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.add-btn:hover:not(:disabled) {
background: rgba(102, 126, 234, 0.3);
}
.add-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.empty-state {
text-align: center;
padding: 1.5rem;
color: #666;
}
.empty-state .hint {
font-size: 0.85rem;
margin-top: 0.5rem;
}
.conditions-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.condition-card {
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 1rem;
}
.condition-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.condition-number {
font-size: 0.85rem;
font-weight: 600;
color: #667eea;
}
.remove-btn {
background: none;
border: none;
color: #888;
cursor: pointer;
padding: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background 0.2s, color 0.2s;
}
.remove-btn:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.remove-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.condition-fields {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.field label {
font-size: 0.8rem;
color: #888;
}
input,
select {
padding: 0.5rem 0.75rem;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.05);
color: #fff;
font-size: 0.9rem;
}
input:focus,
select:focus {
outline: none;
border-color: #667eea;
}
input:disabled,
select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
input[type="number"] {
-moz-appearance: textfield;
}
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.condition-preview {
padding-top: 0.75rem;
border-top: 1px solid rgba(255, 255, 255, 0.05);
font-size: 0.85rem;
color: #aaa;
}
.preview-label {
color: #666;
margin-right: 0.5rem;
}
</style>

View File

@@ -0,0 +1,139 @@
<script lang="ts">
interface Props {
initialBalance?: number;
currentBalance?: number;
position?: number;
positionToken?: string;
entryPrice?: number;
currentPrice?: number;
}
let {
initialBalance = 10000,
currentBalance = 10000,
position = 0,
positionToken = '',
entryPrice = 0,
currentPrice = 0
}: Props = $props();
// Calculate metrics
let positionValue = $derived(position * currentPrice);
let totalValue = $derived(currentBalance + positionValue);
let pnl = $derived(totalValue - initialBalance);
let pnlPercent = $derived((pnl / initialBalance) * 100);
let unrealizedPnL = $derived(position > 0 && entryPrice > 0 ? (currentPrice - entryPrice) / entryPrice * 100 : 0);
</script>
<div class="portfolio-summary">
<div class="metric">
<span class="label">Cash Balance</span>
<span class="value">${currentBalance.toFixed(2)}</span>
</div>
{#if position > 0}
<div class="metric">
<span class="label">Position ({positionToken || 'Token'})</span>
<span class="value highlight">{position.toFixed(6)}</span>
</div>
<div class="metric">
<span class="label">Position Value</span>
<span class="value">${positionValue.toFixed(2)}</span>
</div>
<div class="metric">
<span class="label">Entry Price</span>
<span class="value">${entryPrice.toFixed(8)}</span>
</div>
<div class="metric">
<span class="label">Current Price</span>
<span class="value">${currentPrice.toFixed(8)}</span>
</div>
<div class="metric">
<span class="label">Unrealized P&L</span>
<span class="value" class:positive={unrealizedPnL > 0} class:negative={unrealizedPnL < 0}>
{unrealizedPnL >= 0 ? '+' : ''}{unrealizedPnL.toFixed(2)}%
</span>
</div>
{/if}
<div class="divider"></div>
<div class="metric total">
<span class="label">Total Value</span>
<span class="value">${totalValue.toFixed(2)}</span>
</div>
<div class="metric">
<span class="label">P&L</span>
<span class="value large" class:positive={pnl > 0} class:negative={pnl < 0}>
{pnl >= 0 ? '+' : ''}${pnl.toFixed(2)} ({pnlPercent >= 0 ? '+' : ''}{pnlPercent.toFixed(2)}%)
</span>
</div>
</div>
<style>
.portfolio-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 1rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
}
.metric {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.metric .label {
font-size: 0.75rem;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.metric .value {
font-size: 1rem;
font-weight: 600;
color: #fff;
font-family: monospace;
}
.metric .value.highlight {
color: #fbbf24;
}
.metric .value.large {
font-size: 1.25rem;
}
.metric.total {
grid-column: 1 / -1;
padding-top: 0.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.metric.total .value {
font-size: 1.5rem;
color: #667eea;
}
.positive {
color: #22c55e !important;
}
.negative {
color: #ef4444 !important;
}
.divider {
display: none;
}
</style>

View File

@@ -0,0 +1,121 @@
<script lang="ts">
interface Props {
feature?: string;
dismissible?: boolean;
onDismiss?: () => void;
}
let { feature, dismissible = true, onDismiss }: Props = $props();
</script>
<div class="pro-upgrade-banner">
<div class="banner-content">
<div class="banner-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
</svg>
</div>
<div class="banner-text">
<strong>Upgrade to Pro</strong>
{#if feature}
<p>{feature}</p>
{:else}
<p>Unlock advanced features and unlimited bots</p>
{/if}
</div>
<a href="/settings" class="upgrade-btn">Upgrade Now</a>
</div>
{#if dismissible && onDismiss}
<button class="dismiss-btn" onclick={onDismiss} aria-label="Dismiss">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
{/if}
</div>
<style>
.pro-upgrade-banner {
position: relative;
display: flex;
align-items: center;
background: linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(118, 75, 162, 0.15) 100%);
border: 1px solid rgba(102, 126, 234, 0.3);
border-radius: 12px;
padding: 1rem 1.5rem;
margin: 1rem 0;
}
.banner-content {
display: flex;
align-items: center;
gap: 1rem;
flex: 1;
}
.banner-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: rgba(102, 126, 234, 0.2);
border-radius: 8px;
color: #667eea;
}
.banner-text {
flex: 1;
}
.banner-text strong {
display: block;
color: #fff;
font-size: 1rem;
margin-bottom: 0.25rem;
}
.banner-text p {
margin: 0;
font-size: 0.85rem;
color: #aaa;
}
.upgrade-btn {
padding: 0.5rem 1rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 8px;
text-decoration: none;
font-weight: 500;
font-size: 0.9rem;
transition: transform 0.2s, box-shadow 0.2s;
}
.upgrade-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.dismiss-btn {
position: absolute;
top: 0.5rem;
right: 0.5rem;
background: none;
border: none;
color: #888;
cursor: pointer;
padding: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background 0.2s, color 0.2s;
}
.dismiss-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
</style>

View File

@@ -0,0 +1,266 @@
<script lang="ts">
import type { Signal } from '$lib/api';
import { onMount, tick } from 'svelte';
interface Props {
signals?: Signal[];
klines?: { time: number; close: number }[];
height?: number;
}
let { signals = [], klines = [], height = 200 }: Props = $props();
let width = $state(800);
let containerEl: HTMLDivElement;
let canvasEl: HTMLCanvasElement;
let initialized = $state(false);
onMount(() => {
// Set initial width
if (containerEl) {
width = containerEl.clientWidth;
}
// Resize observer
const resizeObserver = new ResizeObserver(entries => {
for (const entry of entries) {
width = entry.contentRect.width;
drawChart();
}
});
if (containerEl) {
resizeObserver.observe(containerEl);
}
initialized = true;
return () => {
resizeObserver.disconnect();
};
});
// Draw when data changes
$effect(() => {
// Access reactive values to trigger effect
const currentSignals = signals;
const currentKlines = klines;
const currentWidth = width;
// Wait for DOM to be ready
tick().then(() => {
drawChart();
});
});
function drawChart() {
if (!canvasEl) {
return;
}
const ctx = canvasEl.getContext('2d');
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
canvasEl.width = width * dpr;
canvasEl.height = height * dpr;
ctx.scale(dpr, dpr);
// Clear canvas
ctx.clearRect(0, 0, width, height);
// Check if we have data
if (klines.length === 0 && signals.length === 0) {
return;
}
// Get price data
let priceData: { time: number; price: number }[] = [];
if (klines.length > 0) {
priceData = klines.map(k => ({
time: k.time,
price: typeof k.close === 'string' ? parseFloat(k.close) : k.close
})).filter(d => !isNaN(d.price) && d.price > 0);
} else if (signals.length > 0) {
priceData = signals.map(s => ({ time: 0, price: s.price }));
}
if (priceData.length === 0) return;
const prices = priceData.map(d => d.price);
const padding = { top: 20, right: 20, bottom: 45, left: 60 }; // More bottom padding for time labels
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
// Price range with padding
const minPrice = Math.min(...prices);
const maxPrice = Math.max(...prices);
const priceRange = maxPrice - minPrice || 1;
const paddedMin = minPrice - priceRange * 0.1;
const paddedMax = maxPrice + priceRange * 0.1;
function priceToY(price: number): number {
return padding.top + (1 - (price - paddedMin) / (paddedMax - paddedMin)) * chartHeight;
}
function indexToX(index: number): number {
return padding.left + (index / Math.max(prices.length - 1, 1)) * chartWidth;
}
// Draw grid lines
ctx.strokeStyle = 'rgba(255, 255, 255, 0.05)';
ctx.lineWidth = 1;
for (let i = 0; i <= 4; i++) {
const y = padding.top + (i / 4) * chartHeight;
ctx.beginPath();
ctx.moveTo(padding.left, y);
ctx.lineTo(width - padding.right, y);
ctx.stroke();
}
// Draw Y axis labels
ctx.fillStyle = '#888';
ctx.font = '10px monospace';
ctx.textAlign = 'right';
for (let i = 0; i <= 4; i++) {
const price = paddedMax - (i / 4) * (paddedMax - paddedMin);
const y = padding.top + (i / 4) * chartHeight + 4;
ctx.fillText('$' + price.toFixed(6), padding.left - 5, y);
}
// Draw price line
ctx.beginPath();
ctx.strokeStyle = '#667eea';
ctx.lineWidth = 2;
ctx.moveTo(indexToX(0), priceToY(prices[0]));
for (let i = 1; i < prices.length; i++) {
ctx.lineTo(indexToX(i), priceToY(prices[i]));
}
ctx.stroke();
// Fill area under line
ctx.lineTo(indexToX(prices.length - 1), padding.top + chartHeight);
ctx.lineTo(indexToX(0), padding.top + chartHeight);
ctx.closePath();
const gradient = ctx.createLinearGradient(0, padding.top, 0, padding.top + chartHeight);
gradient.addColorStop(0, 'rgba(102, 126, 234, 0.3)');
gradient.addColorStop(1, 'rgba(102, 126, 234, 0)');
ctx.fillStyle = gradient;
ctx.fill();
// Draw signal markers
if (signals.length > 0) {
signals.forEach((signal) => {
// Find closest price match
const signalPrice = signal.price;
let closestIndex = 0;
let closestDiff = Infinity;
for (let i = 0; i < priceData.length; i++) {
const diff = Math.abs(priceData[i].price - signalPrice);
if (diff < closestDiff) {
closestDiff = diff;
closestIndex = i;
}
}
const x = indexToX(closestIndex);
const y = priceToY(signalPrice);
const color = signal.signal_type === 'buy' ? '#22c55e' : '#ef4444';
// Vertical dashed line
ctx.beginPath();
ctx.strokeStyle = color;
ctx.setLineDash([4, 4]);
ctx.moveTo(x, padding.top);
ctx.lineTo(x, y);
ctx.stroke();
ctx.setLineDash([]);
// Signal dot
ctx.beginPath();
ctx.arc(x, y, 6, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.stroke();
});
}
// Draw X axis time labels
ctx.fillStyle = '#666';
ctx.font = '9px monospace';
ctx.textAlign = 'center';
const numTimeLabels = Math.min(5, priceData.length);
for (let i = 0; i < numTimeLabels; i++) {
const dataIndex = Math.floor(i * (priceData.length - 1) / (numTimeLabels - 1 || 1));
const x = indexToX(dataIndex);
// Convert timestamp to readable time
let timeLabel = '';
if (priceData[dataIndex].time > 0) {
const date = new Date(priceData[dataIndex].time * 1000);
timeLabel = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else {
timeLabel = `${dataIndex + 1}`;
}
ctx.fillText(timeLabel, x, height - 5);
}
// Legend
ctx.fillStyle = '#888';
ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
if (signals.length > 0) {
const buyCount = signals.filter(s => s.signal_type === 'buy').length;
const sellCount = signals.filter(s => s.signal_type === 'sell').length;
ctx.fillText(`📈 ${buyCount} Buy | ${sellCount} Sell | ${priceData.length} Candles`, width / 2, height - 20);
} else {
ctx.fillText(`${priceData.length} Candles (No signals generated)`, width / 2, height - 20);
}
}
</script>
<div class="signal-chart" bind:this={containerEl}>
{#if klines.length === 0 && signals.length === 0}
<div class="empty-state">
<p>No data to display. Start a simulation to see price movements.</p>
</div>
{:else}
<canvas
bind:this={canvasEl}
style="width: 100%; height: {height}px;"
></canvas>
{/if}
</div>
<style>
.signal-chart {
width: 100%;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 1rem;
box-sizing: border-box;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
color: #666;
text-align: center;
padding: 1rem;
}
canvas {
display: block;
width: 100%;
}
</style>

View File

@@ -0,0 +1,228 @@
<script lang="ts">
import type { StrategyConfig } from '$lib/api';
interface Props {
config: StrategyConfig | null;
editable?: boolean;
onUpdate?: (config: StrategyConfig) => void;
}
let { config, editable = false, onUpdate }: Props = $props();
function getConditionDescription(condition: StrategyConfig['conditions'][0]): string {
const timeframe = condition.timeframe ? ` within ${condition.timeframe}` : '';
switch (condition.type) {
case 'price_drop':
return `${condition.token} drops by ${condition.threshold}%${timeframe}`;
case 'price_rise':
return `${condition.token} rises by ${condition.threshold}%${timeframe}`;
case 'volume_spike':
return `${condition.token} volume spikes by ${condition.threshold}%${timeframe}`;
case 'price_level':
return `${condition.token} crosses ${condition.direction} $${condition.price}`;
default:
return 'Unknown condition';
}
}
function getActionDescription(action: StrategyConfig['actions'][0]): string {
switch (action.type) {
case 'buy':
return `Buy ${action.amount_percent}% of ${action.token || 'portfolio'}`;
case 'sell':
return `Sell ${action.amount_percent}% of ${action.token || 'portfolio'}`;
case 'hold':
return 'Hold';
default:
return 'Unknown action';
}
}
</script>
<div class="strategy-preview">
{#if !config || (config.conditions.length === 0 && config.actions.length === 0)}
<div class="empty-state">
<p>No strategy configured yet.</p>
<p class="hint">Describe your trading strategy in the chat to create one.</p>
</div>
{:else}
<div class="strategy-section">
<h4>Conditions</h4>
{#if config.conditions.length === 0}
<p class="empty">No conditions set</p>
{:else}
<ul class="items-list">
{#each config.conditions as condition, i}
<li>
<span class="condition-badge">{condition.type.replace('_', ' ')}</span>
{getConditionDescription(condition)}
</li>
{/each}
</ul>
{/if}
</div>
<div class="strategy-section">
<h4>Actions</h4>
{#if config.actions.length === 0}
<p class="empty">No actions set</p>
{:else}
<ul class="items-list">
{#each config.actions as action}
<li>
<span class="action-badge action-{action.type}">{action.type}</span>
{getActionDescription(action)}
</li>
{/each}
</ul>
{/if}
</div>
{#if config.risk_management}
<div class="strategy-section">
<h4>Risk Management</h4>
<div class="risk-items">
{#if config.risk_management.stop_loss_percent}
<div class="risk-item">
<span class="risk-label">Stop Loss</span>
<span class="risk-value negative">{config.risk_management.stop_loss_percent}%</span>
</div>
{/if}
{#if config.risk_management.take_profit_percent}
<div class="risk-item">
<span class="risk-label">Take Profit</span>
<span class="risk-value positive">{config.risk_management.take_profit_percent}%</span>
</div>
{/if}
</div>
</div>
{/if}
{/if}
</div>
<style>
.strategy-preview {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 1rem;
}
.empty-state {
text-align: center;
padding: 1rem;
color: #888;
}
.empty-state .hint {
font-size: 0.85rem;
color: #666;
margin-top: 0.5rem;
}
.strategy-section {
margin-bottom: 1rem;
}
.strategy-section:last-child {
margin-bottom: 0;
}
h4 {
font-size: 0.85rem;
font-weight: 600;
color: #888;
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0 0 0.75rem;
}
.items-list {
list-style: none;
padding: 0;
margin: 0;
}
.items-list li {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0;
font-size: 0.9rem;
color: #ccc;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.items-list li:last-child {
border-bottom: none;
}
.condition-badge,
.action-badge {
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
}
.condition-badge {
background: rgba(102, 126, 234, 0.2);
color: #667eea;
}
.action-badge {
min-width: 50px;
text-align: center;
}
.action-buy {
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
}
.action-sell {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.action-hold {
background: rgba(251, 191, 36, 0.2);
color: #fbbf24;
}
.empty {
color: #666;
font-size: 0.9rem;
font-style: italic;
}
.risk-items {
display: flex;
gap: 1.5rem;
}
.risk-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.risk-label {
font-size: 0.75rem;
color: #888;
}
.risk-value {
font-size: 1.1rem;
font-weight: 600;
}
.positive {
color: #22c55e;
}
.negative {
color: #ef4444;
}
</style>

View File

@@ -0,0 +1,256 @@
<script lang="ts">
import { api } from '$lib/api';
interface Token {
symbol: string;
chain: string;
name: string;
}
interface Props {
selectedToken?: string;
selectedChain?: string;
onSelect: (token: string, chain: string) => void;
disabled?: boolean;
label?: string;
}
let { selectedToken = '', selectedChain = '', onSelect, disabled = false, label = 'Select Token' }: Props = $props();
let searchQuery = $state('');
let isOpen = $state(false);
let tokens = $state<Token[]>([]);
let isLoading = $state(false);
let inputEl: HTMLInputElement;
let containerEl: HTMLDivElement;
const commonTokens: Token[] = [
{ symbol: 'BTC', chain: 'btc', name: 'Bitcoin' },
{ symbol: 'ETH', chain: 'eth', name: 'Ethereum' },
{ symbol: 'BNB', chain: 'bsc', name: 'BNB' },
{ symbol: 'PEPE', chain: 'bsc', name: 'Pepe' },
{ symbol: 'SHIB', chain: 'eth', name: 'Shiba Inu' },
{ symbol: 'DOGE', chain: 'doge', name: 'Dogecoin' },
{ symbol: 'SOL', chain: 'sol', name: 'Solana' },
{ symbol: 'XRP', chain: 'xrp', name: 'Ripple' },
];
$effect(() => {
function handleClickOutside(event: MouseEvent) {
if (containerEl && !containerEl.contains(event.target as Node)) {
isOpen = false;
}
}
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
});
async function loadTokens() {
isLoading = true;
try {
tokens = await api.config.getTokens();
} catch (e) {
tokens = commonTokens;
} finally {
isLoading = false;
}
}
function getFilteredTokens(): Token[] {
const allTokens = tokens.length > 0 ? tokens : commonTokens;
if (!searchQuery) return allTokens.slice(0, 10);
const query = searchQuery.toLowerCase();
return allTokens.filter(
t => t.symbol.toLowerCase().includes(query) ||
t.name.toLowerCase().includes(query) ||
t.chain.toLowerCase().includes(query)
).slice(0, 10);
}
function handleSelect(token: Token) {
onSelect(token.symbol, token.chain);
searchQuery = '';
isOpen = false;
}
function handleInputFocus() {
isOpen = true;
if (tokens.length === 0 && !isLoading) {
loadTokens();
}
}
</script>
<div class="token-picker" bind:this={containerEl}>
{#if label}
<label>{label}</label>
{/if}
<div class="input-wrapper">
<input
type="text"
bind:this={inputEl}
bind:value={searchQuery}
onfocus={handleInputFocus}
placeholder={selectedToken ? `${selectedToken}${selectedChain ? ` (${selectedChain})` : ''}` : 'Search tokens...'}
{disabled}
class:has-value={selectedToken}
/>
{#if selectedToken}
<button class="clear-btn" onclick={() => onSelect('', '')} disabled={disabled}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
{/if}
</div>
{#if isOpen}
<div class="dropdown">
{#if isLoading}
<div class="loading">Loading tokens...</div>
{:else if getFilteredTokens().length === 0}
<div class="no-results">No tokens found</div>
{:else}
{#each getFilteredTokens() as token}
<button
class="token-option"
class:selected={token.symbol === selectedToken && token.chain === selectedChain}
onclick={() => handleSelect(token)}
>
<span class="token-symbol">{token.symbol}</span>
<span class="token-chain">{token.chain.toUpperCase()}</span>
<span class="token-name">{token.name}</span>
</button>
{/each}
{/if}
</div>
{/if}
</div>
<style>
.token-picker {
position: relative;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
label {
font-size: 0.9rem;
color: #888;
}
.input-wrapper {
position: relative;
}
input {
width: 100%;
padding: 0.75rem 2.5rem 0.75rem 1rem;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.05);
color: #fff;
font-size: 1rem;
box-sizing: border-box;
}
input:focus {
outline: none;
border-color: #667eea;
}
input.has-value {
border-color: rgba(102, 126, 234, 0.5);
}
input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.clear-btn {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #888;
cursor: pointer;
padding: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
}
.clear-btn:hover:not(:disabled) {
color: #fff;
}
.dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 0.5rem;
background: #1a1a1a;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
max-height: 250px;
overflow-y: auto;
z-index: 50;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
}
.loading,
.no-results {
padding: 1rem;
text-align: center;
color: #888;
font-size: 0.9rem;
}
.token-option {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.75rem 1rem;
background: none;
border: none;
color: #fff;
text-align: left;
cursor: pointer;
transition: background 0.2s;
}
.token-option:hover {
background: rgba(255, 255, 255, 0.05);
}
.token-option.selected {
background: rgba(102, 126, 234, 0.2);
}
.token-symbol {
font-weight: 600;
min-width: 60px;
}
.token-chain {
font-size: 0.75rem;
padding: 0.15rem 0.4rem;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
color: #888;
}
.token-name {
flex: 1;
color: #888;
font-size: 0.9rem;
}
</style>

View File

@@ -0,0 +1,180 @@
<script lang="ts">
import type { TradeLogEntry } from '$lib/stores/simulationStore';
interface Props {
tradeLog: TradeLogEntry[];
}
let { tradeLog }: Props = $props();
function formatTime(timestamp: number): string {
const date = new Date(timestamp * 1000);
return date.toLocaleString();
}
function getActionColor(action: string): string {
switch (action) {
case 'buy': return '#22c55e';
case 'sell': return '#ef4444';
default: return '#666';
}
}
function getActionIcon(action: string): string {
switch (action) {
case 'buy': return '📈';
case 'sell': return '📉';
default: return '➡️';
}
}
// Filter to show only buy/sell actions
let tradeActions = $derived(tradeLog.filter(t => t.action !== 'hold'));
</script>
<div class="trade-dashboard">
<div class="dashboard-header">
<h3>Trade Activity</h3>
<span class="trade-count">
{tradeActions.length} trades
</span>
</div>
{#if tradeActions.length === 0}
<div class="empty-state">
<p>No trades executed yet. Check the strategy configuration.</p>
</div>
{:else}
<div class="trade-list">
{#each tradeActions as entry}
<div class="trade-entry action-{entry.action}">
<div class="trade-time">
<span class="action-icon">{getActionIcon(entry.action)}</span>
<span class="action-badge" style="background: {getActionColor(entry.action)}">
{entry.action.toUpperCase()}
</span>
<span class="time">{formatTime(entry.time)}</span>
</div>
<div class="trade-details">
<div class="price">
<span class="label">Price:</span>
<span class="value">${entry.price.toFixed(8)}</span>
</div>
<div class="reason">
<span class="label">Reason:</span>
<span class="value">{entry.reason}</span>
</div>
{#if entry.action === 'sell' && entry.position > 0}
<div class="pnl">
<span class="label">Position:</span>
<span class="value">{entry.position.toFixed(6)}</span>
</div>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
<style>
.trade-dashboard {
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
overflow: hidden;
}
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: rgba(255, 255, 255, 0.02);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.dashboard-header h3 {
margin: 0;
font-size: 1rem;
color: #fff;
}
.trade-count {
font-size: 0.85rem;
color: #888;
}
.empty-state {
padding: 2rem;
text-align: center;
color: #666;
}
.trade-list {
max-height: 300px;
overflow-y: auto;
}
.trade-entry {
padding: 0.75rem 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
transition: background 0.2s;
}
.trade-entry:hover {
background: rgba(255, 255, 255, 0.02);
}
.trade-entry.action-buy {
border-left: 3px solid #22c55e;
}
.trade-entry.action-sell {
border-left: 3px solid #ef4444;
}
.trade-time {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.action-icon {
font-size: 1rem;
}
.action-badge {
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: bold;
color: #fff;
}
.time {
font-size: 0.85rem;
color: #888;
}
.trade-details {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.5rem;
font-size: 0.85rem;
}
.trade-details .label {
color: #666;
}
.trade-details .value {
color: #fff;
font-family: monospace;
}
.pnl .value {
color: #fbbf24;
}
</style>

View File

@@ -0,0 +1,11 @@
export { default as ChatInterface } from './ChatInterface.svelte';
export { default as BotCard } from './BotCard.svelte';
export { default as BotSelector } from './BotSelector.svelte';
export { default as StrategyPreview } from './StrategyPreview.svelte';
export { default as SignalChart } from './SignalChart.svelte';
export { default as TradeDashboard } from './TradeDashboard.svelte';
export { default as PortfolioSummary } from './PortfolioSummary.svelte';
export { default as BacktestChart } from './BacktestChart.svelte';
export { default as ProUpgradeBanner } from './ProUpgradeBanner.svelte';
export { default as TokenPicker } from './TokenPicker.svelte';
export { default as ConditionBuilder } from './ConditionBuilder.svelte';

View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View File

@@ -0,0 +1,50 @@
import { writable, get } from 'svelte/store';
import { api } from '$lib/api';
import { setUser, clearUser, clearBots } from './index';
import { clearSimulationState } from './simulationStore';
import { clearBacktestState } from './backtestStore';
export const isAuthenticated = writable(false);
export const isLoading = writable(true);
export async function initAuth() {
isLoading.set(true);
const token = localStorage.getItem('token');
if (token) {
try {
const user = await api.auth.me();
setUser(user);
isAuthenticated.set(true);
} catch {
localStorage.removeItem('token');
isAuthenticated.set(false);
}
}
isLoading.set(false);
}
export async function login(email: string, password: string) {
const response = await api.auth.login(email, password);
localStorage.setItem('token', response.access_token);
const user = await api.auth.me();
setUser(user);
isAuthenticated.set(true);
}
export async function register(email: string, password: string) {
const response = await api.auth.register(email, password);
localStorage.setItem('token', response.access_token);
const user = await api.auth.me();
setUser(user);
isAuthenticated.set(true);
}
export function logout() {
api.auth.logout().catch(() => {});
localStorage.removeItem('token');
clearUser();
clearBots();
clearBacktestState();
clearSimulationState();
isAuthenticated.set(false);
}

View File

@@ -0,0 +1,45 @@
import { writable } from 'svelte/store';
import type { Backtest, BacktestResult } from '$lib/api';
export interface BacktestState {
currentBacktest: Backtest | null;
backtestHistory: Backtest[];
isLoading: boolean;
error: string | null;
}
const initialState: BacktestState = {
currentBacktest: null,
backtestHistory: [],
isLoading: false,
error: null
};
export const backtestStore = writable<BacktestState>(initialState);
export function setCurrentBacktest(backtest: Backtest | null) {
backtestStore.update(state => ({ ...state, currentBacktest: backtest }));
}
export function addBacktestToHistory(backtest: Backtest) {
backtestStore.update(state => ({
...state,
backtestHistory: [backtest, ...state.backtestHistory]
}));
}
export function setBacktestHistory(backtests: Backtest[]) {
backtestStore.update(state => ({ ...state, backtestHistory: backtests }));
}
export function setBacktestLoading(loading: boolean) {
backtestStore.update(state => ({ ...state, isLoading: loading }));
}
export function setBacktestError(error: string | null) {
backtestStore.update(state => ({ ...state, error }));
}
export function clearBacktestState() {
backtestStore.set(initialState);
}

View File

@@ -0,0 +1,24 @@
import { writable } from 'svelte/store';
import type { Bot } from '$lib/api';
export const botsStore = writable<Bot[]>([]);
export function setBots(bots: Bot[]) {
botsStore.set(bots);
}
export function addBot(bot: Bot) {
botsStore.update(bots => [...bots, bot]);
}
export function updateBot(bot: Bot) {
botsStore.update(bots => bots.map(b => b.id === bot.id ? bot : b));
}
export function removeBot(botId: string) {
botsStore.update(bots => bots.filter(b => b.id !== botId));
}
export function clearBots() {
botsStore.set([]);
}

View File

@@ -0,0 +1,48 @@
import { writable } from 'svelte/store';
import type { BotConversation } from '$lib/api';
export interface ChatMessage {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
thinking: string | null;
timestamp: Date;
}
// Fallback UUID generator for environments where crypto.randomUUID is not available
function generateId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
// Fallback: simple UUID v4 implementation
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
export const chatStore = writable<ChatMessage[]>([]);
export function addMessage(message: Omit<ChatMessage, 'id' | 'timestamp'>) {
const newMessage: ChatMessage = {
...message,
id: generateId(),
timestamp: new Date()
};
chatStore.update(messages => [...messages, newMessage]);
}
export function setMessages(messages: BotConversation[]) {
chatStore.set(messages.map(m => ({
id: m.id,
role: m.role,
content: m.content,
thinking: null,
timestamp: new Date(m.created_at)
})));
}
export function clearChat() {
chatStore.set([]);
}

View File

@@ -0,0 +1,12 @@
import { writable } from 'svelte/store';
import type { Bot } from '$lib/api';
export const currentBotStore = writable<Bot | null>(null);
export function setCurrentBot(bot: Bot | null) {
currentBotStore.set(bot);
}
export function clearCurrentBot() {
currentBotStore.set(null);
}

View File

@@ -0,0 +1,30 @@
export { userStore, setUser, clearUser } from './userStore';
export { botsStore, setBots, addBot, updateBot, removeBot, clearBots } from './botsStore';
export { currentBotStore, setCurrentBot, clearCurrentBot } from './currentBotStore';
export { chatStore, addMessage, setMessages, clearChat } from './chatStore';
export {
backtestStore,
setCurrentBacktest,
addBacktestToHistory,
setBacktestHistory,
setBacktestLoading,
setBacktestError,
clearBacktestState
} from './backtestStore';
export {
simulationStore,
setCurrentSimulation,
addSignals,
clearSignals,
setSimulationLoading,
setSimulationError,
clearSimulationState
} from './simulationStore';
export {
isAuthenticated,
isLoading,
initAuth,
login,
register,
logout
} from './authStore';

View File

@@ -0,0 +1,94 @@
import { writable } from 'svelte/store';
import type { Simulation, Signal } from '$lib/api';
export interface KlineData {
time: number;
close: number;
}
export interface TradeLogEntry {
time: number;
price: number;
action: 'buy' | 'sell' | 'hold';
reason: string;
position: number;
entry_price: number | null;
}
export interface Portfolio {
initial_balance: number;
current_balance: number;
position: number;
position_token: string;
entry_price: number;
current_price: number;
}
export interface SimulationState {
currentSimulation: Simulation | null;
signals: Signal[];
klines: KlineData[];
tradeLog: TradeLogEntry[];
portfolio: Portfolio;
isLoading: boolean;
error: string | null;
}
const initialState: SimulationState = {
currentSimulation: null,
signals: [],
klines: [],
tradeLog: [],
portfolio: {
initial_balance: 10000,
current_balance: 10000,
position: 0,
position_token: '',
entry_price: 0,
current_price: 0
},
isLoading: false,
error: null
};
export const simulationStore = writable<SimulationState>(initialState);
export function setCurrentSimulation(simulation: Simulation | null) {
simulationStore.update(state => ({
...state,
currentSimulation: simulation,
klines: simulation?.klines || [],
tradeLog: simulation?.trade_log || [],
portfolio: simulation?.portfolio || state.portfolio
}));
}
export function updatePortfolio(portfolio: Partial<Portfolio>) {
simulationStore.update(state => ({
...state,
portfolio: { ...state.portfolio, ...portfolio }
}));
}
export function addSignals(newSignals: Signal[]) {
simulationStore.update(state => ({
...state,
signals: [...state.signals, ...newSignals]
}));
}
export function clearSignals() {
simulationStore.update(state => ({ ...state, signals: [] }));
}
export function setSimulationLoading(loading: boolean) {
simulationStore.update(state => ({ ...state, isLoading: loading }));
}
export function setSimulationError(error: string | null) {
simulationStore.update(state => ({ ...state, error }));
}
export function clearSimulationState() {
simulationStore.set(initialState);
}

View File

@@ -0,0 +1,12 @@
import { writable } from 'svelte/store';
import type { User } from '$lib/api';
export const userStore = writable<User | null>(null);
export function setUser(user: User | null) {
userStore.set(user);
}
export function clearUser() {
userStore.set(null);
}

View File

@@ -0,0 +1,256 @@
/**
* Simple markdown parser for rendering AI responses
* Supports: bold, italic, code blocks, inline code, links, lists, tables, headings, line breaks
*/
export interface InlineSegment {
type: 'text' | 'bold' | 'italic' | 'code' | 'link';
content: string;
href?: string;
}
export interface ParsedSegment {
type: 'text' | 'bold' | 'italic' | 'code' | 'codeBlock' | 'link' | 'list' | 'table' | 'lineBreak' | 'heading';
content: string;
items?: string[];
headers?: InlineSegment[][];
rows?: InlineSegment[][];
}
export function parseMarkdown(text: string): ParsedSegment[] {
const segments: ParsedSegment[] = [];
// Normalize line endings
text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
// First, extract code blocks
const codeBlockRegex = /```[\s\S]*?```/g;
const parts = text.split(codeBlockRegex);
const codeBlocks = text.match(codeBlockRegex) || [];
let partIndex = 0;
while (partIndex < parts.length) {
const part = parts[partIndex];
if (part.trim()) {
// Process non-code content
segments.push(...parseInlineContent(part));
}
// Add code block if there's one after this part
if (partIndex < codeBlocks.length) {
const codeContent = codeBlocks[partIndex].replace(/^```\w*\n?/, '').replace(/```$/, '');
segments.push({ type: 'codeBlock', content: codeContent });
}
partIndex++;
}
return segments;
}
function parseInlineContent(text: string): ParsedSegment[] {
const segments: ParsedSegment[] = [];
// Check for tables - match table pattern anywhere in text
// Table pattern: | header | ... |\n|---|...|\n| row | ... |
const tableRegex = /\|.+\|\n\|[-:\s|]+\|\n((?:\|.+\|\n?)*)/g;
let lastIndex = 0;
let tableMatch;
while ((tableMatch = tableRegex.exec(text)) !== null) {
// Add content before table
const beforeTable = text.substring(lastIndex, tableMatch.index);
if (beforeTable.trim()) {
segments.push(...parseLines(beforeTable));
}
// Parse table
const tableContent = tableMatch[0];
const tableSegments = parseTable(tableContent);
if (tableSegments.length > 0) {
segments.push(...tableSegments);
} else {
// If table parsing failed, treat as text
segments.push(...parseLines(tableContent));
}
lastIndex = tableMatch.index + tableContent.length;
}
// Add remaining content
if (lastIndex < text.length) {
const remaining = text.substring(lastIndex);
if (remaining.trim()) {
segments.push(...parseLines(remaining));
}
}
return segments;
}
function parseTable(tableStr: string): ParsedSegment[] {
const lines = tableStr.trim().split('\n').filter(line => line.trim());
if (lines.length < 2) return [];
// Skip separator line (|---|---|)
const dataLines = lines.filter(line => !line.match(/^[\|\s\-:]+$/));
if (dataLines.length < 2) return [];
const headers = parseTableRow(dataLines[0]);
const rows = dataLines.slice(1).map(row => parseTableRow(row));
return [{
type: 'table',
content: '',
headers,
rows
}];
}
function parseTableRow(row: string): InlineSegment[][] {
return row.split('|')
.map(cell => cell.trim())
.filter(cell => cell !== '')
.map(cell => parseInlineElements(cell));
}
export function parseInlineElements(text: string): InlineSegment[] {
const segments: InlineSegment[] = [];
const inlineRegex = /(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`|\[.*?\]\(.*?\))/g;
const parts = text.split(inlineRegex);
for (const part of parts) {
if (!part) continue;
if (part.startsWith('**') && part.endsWith('**')) {
segments.push({ type: 'bold', content: part.slice(2, -2) });
} else if (part.startsWith('*') && part.endsWith('*')) {
segments.push({ type: 'italic', content: part.slice(1, -1) });
} else if (part.startsWith('`') && part.endsWith('`')) {
segments.push({ type: 'code', content: part.slice(1, -1) });
} else if (part.startsWith('[') && part.includes('](')) {
const linkMatch = part.match(/\[(.*?)\]\((.*?)\)/);
if (linkMatch) {
segments.push({ type: 'link', content: linkMatch[1], href: linkMatch[2] });
}
} else if (part) {
segments.push({ type: 'text', content: part });
}
}
return segments;
}
// Render inline segments to HTML string
function renderInlineSegments(segments: InlineSegment[]): string {
return segments.map(seg => {
switch (seg.type) {
case 'bold': return `<strong>${seg.content}</strong>`;
case 'italic': return `<em>${seg.content}</em>`;
case 'code': return `<code class="inline-code">${seg.content}</code>`;
case 'link': return `<a href="${seg.href || '#'}" target="_blank" rel="noopener noreferrer">${seg.content}</a>`;
default: return seg.content;
}
}).join('');
}
function parseLines(text: string): ParsedSegment[] {
const segments: ParsedSegment[] = [];
// Combined regex for inline formatting
const inlineRegex = /(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`|\[.*?\]\(.*?\))/g;
const lines = text.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line.trim()) {
// Empty line - add line break for paragraph separation
segments.push({ type: 'lineBreak', content: '' });
continue;
}
// Check for headings
if (line.match(/^#{1,6}\s/)) {
segments.push({ type: 'heading', content: line.replace(/^#+\s/, '') });
continue;
}
// Check for list items
if (line.match(/^[\-\*]\s/)) {
const listMatch = line.match(/^([\-\*])\s(.*)/);
if (listMatch) {
// Parse inline formatting for list item
const itemContent = listMatch[2];
const inlineSegments = parseInlineElements(itemContent);
// Check if previous segment is a list
const lastSeg = segments[segments.length - 1];
if (lastSeg && lastSeg.type === 'list') {
lastSeg.items?.push(itemContent);
} else {
segments.push({ type: 'list', content: '', items: [itemContent] });
}
}
continue;
}
// Check for numbered lists
if (line.match(/^\d+\.\s/)) {
const listMatch = line.match(/^\d+\.\s(.*)/);
if (listMatch) {
const itemContent = listMatch[1];
const lastSeg = segments[segments.length - 1];
if (lastSeg && lastSeg.type === 'list') {
lastSeg.items?.push(itemContent);
} else {
segments.push({ type: 'list', content: '', items: [itemContent] });
}
}
continue;
}
// Process inline formatting
const inlineSegments = parseInlineElementsAsText(line);
segments.push(...inlineSegments);
// Add line break after non-empty lines (except last in a paragraph)
if (i < lines.length - 1 && line.trim()) {
segments.push({ type: 'lineBreak', content: '' });
}
}
return segments;
}
function parseInlineElementsAsText(text: string): ParsedSegment[] {
const segments: ParsedSegment[] = [];
const inlineRegex = /(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`|\[.*?\]\(.*?\))/g;
const parts = text.split(inlineRegex);
for (const part of parts) {
if (!part) continue;
if (part.startsWith('**') && part.endsWith('**')) {
segments.push({ type: 'bold', content: part.slice(2, -2) });
} else if (part.startsWith('*') && part.endsWith('*')) {
segments.push({ type: 'italic', content: part.slice(1, -1) });
} else if (part.startsWith('`') && part.endsWith('`')) {
segments.push({ type: 'code', content: part.slice(1, -1) });
} else if (part.startsWith('[') && part.includes('](')) {
const linkMatch = part.match(/\[(.*?)\]\((.*?)\)/);
if (linkMatch) {
segments.push({ type: 'link', content: linkMatch[1] });
}
} else if (part) {
segments.push({ type: 'text', content: part });
}
}
return segments;
}

View File

@@ -0,0 +1,54 @@
<script lang="ts">
import { onMount } from 'svelte';
import { initAuth, isLoading } from '$lib/stores';
import favicon from '$lib/assets/favicon.svg';
let { children } = $props();
onMount(() => {
initAuth();
});
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
{#if $isLoading}
<div class="loading">
<div class="spinner"></div>
</div>
{:else}
{@render children()}
{/if}
<style>
:global(body) {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f0f0f;
color: #fff;
}
.loading {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(255, 255, 255, 0.1);
border-top-color: #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -0,0 +1,135 @@
<script lang="ts">
import { isAuthenticated } from '$lib/stores';
</script>
<svelte:head>
<title>Randebu - AI Trading Bot Platform</title>
</svelte:head>
{#if $isAuthenticated}
<script>
window.location.href = '/dashboard';
</script>
{:else}
<main>
<div class="hero">
<h1>Randebu</h1>
<p class="tagline">Create trading bots through conversation with AI</p>
<div class="cta">
<a href="/register" class="btn btn-primary">Get Started</a>
<a href="/login" class="btn btn-secondary">Login</a>
</div>
</div>
<section class="features">
<h2>How It Works</h2>
<div class="feature-grid">
<div class="feature">
<h3>1. Describe Your Strategy</h3>
<p>Tell our AI what kind of trading you want to do in plain English</p>
</div>
<div class="feature">
<h3>2. Backtest & Validate</h3>
<p>Test your strategy against historical data before risking real funds</p>
</div>
<div class="feature">
<h3>3. Simulate & Monitor</h3>
<p>Run real-time simulations and watch for trading signals</p>
</div>
</div>
</section>
</main>
{/if}
<style>
:global(body) {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f0f0f;
color: #fff;
}
main {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
}
.hero {
text-align: center;
max-width: 600px;
}
h1 {
font-size: 3.5rem;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.tagline {
font-size: 1.25rem;
color: #aaa;
margin: 1rem 0 2rem;
}
.cta {
display: flex;
gap: 1rem;
justify-content: center;
}
.btn {
padding: 0.75rem 1.5rem;
border-radius: 8px;
text-decoration: none;
font-weight: 500;
transition: transform 0.2s, opacity 0.2s;
}
.btn:hover {
transform: translateY(-2px);
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-secondary {
background: rgba(255, 255, 255, 0.1);
color: white;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.features {
margin-top: 4rem;
text-align: center;
}
.features h2 {
font-size: 2rem;
margin-bottom: 2rem;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 2rem;
max-width: 800px;
}
.feature h3 {
font-size: 1.25rem;
margin-bottom: 0.5rem;
}
.feature p {
color: #888;
font-size: 0.9rem;
}
</style>

View File

@@ -0,0 +1,446 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { isAuthenticated, isLoading, chatStore, addMessage, setMessages, clearChat, currentBotStore, setCurrentBot } from '$lib/stores';
import { api } from '$lib/api';
import { ChatInterface, StrategyPreview } from '$lib/components';
import type { TokenSearchResult } from '$lib/api';
let botId = $derived($page.params.id);
let isSending = $state(false);
let showStrategy = $state(false);
// Token address confirmation modal state
let showTokenConfirm = $state(false);
let pendingStrategyData = $state<any>(null);
let tokenAddressInput = $state('');
let confirmingMessage = $state('');
let tokenSearchResults = $state<TokenSearchResult[]>([]);
onMount(async () => {
if (!$isAuthenticated && !$isLoading) {
goto('/login');
return;
}
if ($isAuthenticated && botId) {
await loadBot();
await loadChatHistory();
}
});
async function loadBot() {
try {
const bot = await api.bots.get(botId);
setCurrentBot(bot);
} catch (e) {
goto('/dashboard');
}
}
async function loadChatHistory() {
try {
const history = await api.bots.getHistory(botId);
setMessages(history);
} catch (e) {
console.error('Failed to load chat history:', e);
}
}
async function handleSendMessage(message: string) {
if (isSending) return;
isSending = true;
// Add user's message immediately so it shows even before API response
addMessage({ role: 'user', content: message });
try {
// Add timeout to prevent hanging requests
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000);
const response = await api.bots.chat(botId, message, controller.signal);
clearTimeout(timeoutId);
// Check if token address confirmation is needed
if (response.strategy_needs_confirmation && response.strategy_data) {
// Show token confirmation modal
pendingStrategyData = response.strategy_data;
confirmingMessage = response.response;
tokenAddressInput = '';
tokenSearchResults = response.token_search_results || [];
showTokenConfirm = true;
}
// Add assistant response with thinking
addMessage({ role: 'assistant', content: response.response, thinking: response.thinking || null });
if (response.strategy_config) {
const bot = await api.bots.get(botId);
setCurrentBot(bot);
}
} catch (e) {
if (e instanceof Error && e.name === 'AbortError') {
addMessage({ role: 'assistant', content: 'Request timed out. Please try again.', thinking: null });
} else {
addMessage({ role: 'assistant', content: 'Sorry, I encountered an error. Please try again.', thinking: null });
}
} finally {
isSending = false;
}
}
function toggleStrategy() {
showStrategy = !showStrategy;
}
async function confirmTokenAddress() {
if (!tokenAddressInput.trim() || !pendingStrategyData) {
showTokenConfirm = false;
return;
}
// Update the pending strategy with the token address
const updatedStrategy = { ...pendingStrategyData };
// Update conditions with token address
if (updatedStrategy.conditions) {
updatedStrategy.conditions = updatedStrategy.conditions.map((cond: any) => ({
...cond,
token_address: tokenAddressInput.trim()
}));
}
// Update actions with token address
if (updatedStrategy.actions) {
updatedStrategy.actions = updatedStrategy.actions.map((action: any) => ({
...action,
token_address: tokenAddressInput.trim()
}));
}
try {
// Update bot with the strategy
await api.bots.update(botId, { strategy_config: updatedStrategy });
// Refresh bot data
const bot = await api.bots.get(botId);
setCurrentBot(bot);
// Add success message
addMessage({ role: 'assistant', content: `Perfect! I've saved your strategy with the token address. You can now run backtests!`, thinking: null });
} catch (e) {
addMessage({ role: 'assistant', content: 'Failed to save strategy. Please try again.', thinking: null });
}
showTokenConfirm = false;
pendingStrategyData = null;
tokenAddressInput = '';
tokenSearchResults = [];
}
function selectTokenResult(result: TokenSearchResult) {
tokenAddressInput = result.address;
}
function cancelTokenConfirm() {
showTokenConfirm = false;
pendingStrategyData = null;
tokenAddressInput = '';
tokenSearchResults = [];
}
</script>
<svelte:head>
<title>{$currentBotStore?.name || 'Bot'} - Randebu</title>
</svelte:head>
<main>
{#if showTokenConfirm}
<div class="modal-overlay" onclick={cancelTokenConfirm}>
<div class="modal-content" onclick={(e) => e.stopPropagation()}>
<h3>Select Token Address</h3>
<p class="modal-message">{confirmingMessage}</p>
{#if tokenSearchResults.length > 0}
<div class="token-results">
<p class="modal-hint">Select a token:</p>
{#each tokenSearchResults as result}
<button class="token-result" onclick={() => selectTokenResult(result)}>
<span class="token-symbol">{result.symbol}</span>
<span class="token-name">{result.name}</span>
<span class="token-address">{result.address.slice(0, 10)}...{result.address.slice(-8)}</span>
</button>
{/each}
</div>
<p class="modal-divider">or enter manually:</p>
{/if}
<input type="text" class="token-input" bind:value={tokenAddressInput} placeholder="0x..."/>
<div class="modal-actions">
<button class="btn btn-secondary" onclick={cancelTokenConfirm}>Cancel</button>
<button class="btn btn-primary" onclick={confirmTokenAddress} disabled={!tokenAddressInput.trim()}>Confirm</button>
</div>
</div>
</div>
{/if}
<header>
<div class="header-left">
<a href="/dashboard" class="back-link">← Dashboard</a>
<h1>{$currentBotStore?.name || 'Loading...'}</h1>
</div>
<div class="header-actions">
{#if $currentBotStore?.strategy_config}
<button class="btn btn-secondary" onclick={toggleStrategy}>
{showStrategy ? 'Hide' : 'Show'} Strategy
</button>
{/if}
<a href="/bot/{botId}/backtest" class="btn btn-secondary">Backtest</a>
<a href="/bot/{botId}/simulate" class="btn btn-secondary">Simulate</a>
</div>
</header>
{#if showStrategy && $currentBotStore?.strategy_config}
<div class="strategy-panel">
<StrategyPreview config={$currentBotStore.strategy_config} />
</div>
{/if}
<div class="chat-wrapper">
<ChatInterface
bot={$currentBotStore}
messages={$chatStore}
isSending={isSending}
onSendMessage={handleSendMessage}
/>
</div>
<!-- <ProUpgradeBanner feature="Auto-execute trades with your bot" /> -->
</main>
<style>
:global(body) {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f0f0f;
color: #fff;
}
main {
height: 100vh;
display: flex;
flex-direction: column;
max-width: 900px;
margin: 0 auto;
padding: 1rem;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
margin-bottom: 1rem;
}
.header-left {
display: flex;
align-items: center;
gap: 1rem;
}
.back-link {
color: #667eea;
text-decoration: none;
font-size: 0.9rem;
}
.back-link:hover {
text-decoration: underline;
}
h1 {
margin: 0;
font-size: 1.5rem;
}
.header-actions {
display: flex;
gap: 0.75rem;
}
.btn {
padding: 0.5rem 1rem;
border-radius: 8px;
text-decoration: none;
font-weight: 500;
font-size: 0.9rem;
cursor: pointer;
border: none;
transition: transform 0.2s;
}
.btn-secondary {
background: rgba(255, 255, 255, 0.1);
color: white;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.btn:hover {
transform: translateY(-2px);
}
.strategy-panel {
margin-bottom: 1rem;
}
.chat-wrapper {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Modal Styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: rgba(20, 20, 20, 0.95);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 1.5rem;
max-width: 450px;
width: 90%;
}
.modal-content h3 {
margin: 0 0 1rem;
color: #667eea;
}
.modal-message {
color: #ccc;
margin-bottom: 0.5rem;
line-height: 1.5;
}
.modal-hint {
color: #888;
font-size: 0.9rem;
margin-bottom: 1rem;
}
.token-input {
width: 100%;
padding: 0.75rem;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.05);
color: #fff;
font-size: 1rem;
font-family: 'Monaco', 'Menlo', monospace;
box-sizing: border-box;
}
.token-input:focus {
outline: none;
border-color: #667eea;
}
.modal-actions {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
justify-content: flex-end;
}
.btn-primary {
padding: 0.75rem 1.5rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Token Results */
.token-results {
max-height: 200px;
overflow-y: auto;
margin-bottom: 1rem;
}
.token-result {
width: 100%;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
margin-bottom: 0.5rem;
cursor: pointer;
text-align: left;
color: #fff;
transition: background 0.2s;
}
.token-result:hover {
background: rgba(102, 126, 234, 0.2);
border-color: rgba(102, 126, 234, 0.5);
}
.token-result:last-child {
margin-bottom: 0;
}
.token-symbol {
font-weight: 600;
color: #667eea;
min-width: 60px;
}
.token-name {
flex: 1;
color: #ccc;
font-size: 0.9rem;
}
.token-address {
font-size: 0.75rem;
color: #666;
font-family: 'Monaco', 'Menlo', monospace;
}
.modal-divider {
text-align: center;
color: #666;
font-size: 0.85rem;
margin: 1rem 0;
}
</style>

View File

@@ -0,0 +1,922 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { isAuthenticated, isLoading, currentBotStore, setCurrentBot, backtestStore, setCurrentBacktest, addBacktestToHistory, setBacktestLoading, setBacktestError } from '$lib/stores';
import { api } from '$lib/api';
import { BacktestChart } from '$lib/components';
import type { Backtest } from '$lib/api';
let botId = $derived($page.params.id);
let tokenName = $state('');
let tokenAddress = $state('');
let timeframe = $state('1h');
let startDate = $state('');
let endDate = $state('');
let isRunning = $state(false);
let selectedBacktest = $state<Backtest | null>(null);
// Expandable trades state
let expandedTrades = $state<Set<string>>(new Set());
// Pagination state for each backtest
let tradesPage = $state<Record<string, number>>({});
let tradesData = $state<Record<string, any>>({});
const TRADES_PER_PAGE = 5;
onMount(async () => {
// Set default dates - yesterday only (1 day range for fast testing)
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
// Set max date to yesterday
const maxDate = yesterday.toISOString().split('T')[0];
// Set end to yesterday, start to day before (1 day range)
endDate = maxDate;
const dayBefore = new Date(yesterday);
dayBefore.setDate(dayBefore.getDate() - 1);
startDate = dayBefore.toISOString().split('T')[0];
if (!$isAuthenticated && !$isLoading) {
goto('/login');
return;
}
if ($isAuthenticated && botId) {
await loadBot();
await loadBacktests();
}
});
async function loadBot() {
try {
const bot = await api.bots.get(botId);
setCurrentBot(bot);
// Extract token info from strategy config
const strategy = bot.strategy_config;
if (strategy) {
// Try conditions first, then actions
const condition = strategy.conditions?.[0];
const action = strategy.actions?.[0];
tokenName = condition?.token || action?.token || '';
tokenAddress = condition?.token_address || action?.token_address || '';
}
} catch (e) {
goto('/dashboard');
}
}
async function loadBacktests() {
try {
const backtests = await api.backtest.list(botId);
setBacktestHistory(backtests);
} catch (e) {
console.error('Failed to load backtests:', e);
}
}
async function startBacktest() {
if (!startDate || !endDate) return;
// Validate date range (max 7 days)
const start = new Date(startDate);
const end = new Date(endDate);
const daysDiff = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
if (daysDiff > 7) {
setBacktestError('Maximum backtest duration is 7 days for fast testing');
return;
}
setBacktestError(null);
setBacktestLoading(true);
isRunning = true;
try {
const backtest = await api.backtest.start(botId, {
token: tokenAddress, // Use token address from strategy
token_name: tokenName, // Also send token name for display
timeframe,
start_date: startDate,
end_date: endDate
});
setCurrentBacktest(backtest);
addBacktestToHistory(backtest);
} catch (e) {
setBacktestError(e instanceof Error ? e.message : 'Failed to start backtest');
} finally {
setBacktestLoading(false);
isRunning = false;
}
}
async function stopBacktest(runId: string) {
try {
await api.backtest.stop(botId, runId);
await loadBacktests();
} catch (e) {
console.error('Failed to stop backtest:', e);
}
}
function setBacktestHistory(backtests: any[]) {
backtestStore.update(state => ({ ...state, backtestHistory: backtests }));
}
function selectBacktest(backtest: Backtest) {
if (backtest.status === 'completed' && backtest.result && !backtest.result.error) {
selectedBacktest = backtest;
}
}
function toggleTrades(backtestId: string) {
if (expandedTrades.has(backtestId)) {
expandedTrades.delete(backtestId);
} else {
expandedTrades.add(backtestId);
// Load first page of trades if not loaded
if (!tradesData[backtestId]) {
loadTrades(backtestId, 1);
}
}
expandedTrades = new Set(expandedTrades); // Trigger reactivity
}
async function loadTrades(backtestId: string, page: number) {
try {
const data = await api.backtest.getTrades(botId, backtestId, page, TRADES_PER_PAGE);
tradesData[backtestId] = { ...data, currentPage: page };
tradesData = { ...tradesData }; // Trigger reactivity
} catch (e) {
console.error('Failed to load trades:', e);
}
}
function nextTradesPage(backtestId: string) {
const data = tradesData[backtestId];
if (data && data.has_next) {
loadTrades(backtestId, data.page + 1);
}
}
function prevTradesPage(backtestId: string) {
const data = tradesData[backtestId];
if (data && data.has_prev) {
loadTrades(backtestId, data.page - 1);
}
}
</script>
<svelte:head>
<title>Backtest - {$currentBotStore?.name || 'Bot'} - Randebu</title>
</svelte:head>
<main>
<header>
<div class="header-left">
<a href="/bot/{botId}" class="back-link">← Back to Chat</a>
<h1>Backtest</h1>
</div>
</header>
<div class="content">
<section class="config-section">
<h2>Configure Backtest</h2>
{#if $backtestStore.error}
<div class="error">{$backtestStore.error}</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); startBacktest(); }}>
<div class="form-row">
<div class="field token-info">
<label>Token</label>
<div class="token-display">
<span class="token-name">{tokenName || 'Not configured'}</span>
{#if tokenAddress}
<span class="token-address">{tokenAddress.slice(0, 10)}...{tokenAddress.slice(-8)}</span>
{/if}
</div>
</div>
<div class="field">
<label for="timeframe">Timeframe</label>
<select id="timeframe" bind:value={timeframe}>
<option value="1h">1 hour (recommended)</option>
<option value="4h">4 hours</option>
<option value="1d">1 day</option>
</select>
</div>
</div>
<div class="form-row">
<div class="field">
<label for="startDate">Start Date</label>
<input type="date" id="startDate" bind:value={startDate} required />
</div>
<div class="field">
<label for="endDate">End Date</label>
<input type="date" id="endDate" bind:value={endDate} required />
</div>
</div>
<button type="submit" disabled={isRunning || $backtestStore.isLoading}>
{isRunning ? 'Running...' : 'Start Backtest'}
</button>
</form>
</section>
<section class="results-section">
<div class="section-header">
<h2>Backtest History</h2>
<button class="btn-refresh" onclick={() => loadBacktests()} disabled={$backtestStore.isLoading}>
{$backtestStore.isLoading ? 'Refreshing...' : 'Refresh'}
</button>
</div>
{#if $backtestStore.backtestHistory.length === 0}
<p class="empty-state">No backtests yet. Run your first backtest above.</p>
{:else}
<div class="backtest-list">
{#each $backtestStore.backtestHistory as backtest}
<div class="backtest-card">
<div class="backtest-header">
<span class="backtest-status status-{backtest.status}">{backtest.status}</span>
<span class="backtest-date">{new Date(backtest.started_at).toLocaleDateString()}</span>
</div>
{#if backtest.result && backtest.result.error}
<div class="backtest-error">
<span class="error-label">Error:</span> {typeof backtest.result.error === 'string' ? backtest.result.error : JSON.stringify(backtest.result.error)}
</div>
{:else if backtest.result}
<div class="backtest-results">
<div class="result-item">
<span class="result-label">Total Return</span>
<span class="result-value" class:positive={backtest.result.total_return > 0} class:negative={backtest.result.total_return < 0}>
{backtest.result.total_return.toFixed(2)}%
</span>
</div>
<div class="result-item">
<span class="result-label">Win Rate</span>
<span class="result-value">{backtest.result.win_rate.toFixed(1)}%</span>
</div>
<div class="result-item">
<span class="result-label">Total Trades</span>
<span class="result-value">{backtest.result.total_trades}</span>
</div>
<div class="result-item">
<span class="result-label">Max Drawdown</span>
<span class="result-value negative">{backtest.result.max_drawdown.toFixed(2)}%</span>
</div>
</div>
<div class="backtest-config">
<span class="config-item">
<span class="config-label">Token:</span> {backtest.config.token || 'Unknown'}
</span>
<span class="config-item">
<span class="config-label">TF:</span> {backtest.config.timeframe || '1h'}
</span>
<span class="config-item">
<span class="config-label">Period:</span> {backtest.config.start_date} to {backtest.config.end_date}
</span>
</div>
{#if backtest.result.trades && backtest.result.trades.length > 0}
<button class="btn-toggle-trades" onclick={() => toggleTrades(backtest.id)}>
{expandedTrades.has(backtest.id) ? 'Hide' : 'Show'} Trade History ({backtest.result.trades.length})
</button>
{#if expandedTrades.has(backtest.id)}
<div class="trades-inline">
{#if tradesData[backtest.id]}
<div class="trades-pagination-header">
<span class="trades-count">
Showing {((tradesData[backtest.id].page - 1) * TRADES_PER_PAGE) + 1}-{Math.min(tradesData[backtest.id].page * TRADES_PER_PAGE, tradesData[backtest.id].total_trades)} of {tradesData[backtest.id].total_trades}
</span>
{#if tradesData[backtest.id].total_pages > 1}
<div class="pagination-controls">
<button class="btn-pagination" onclick={() => prevTradesPage(backtest.id)} disabled={!tradesData[backtest.id].has_prev}>← Prev</button>
<span class="page-indicator">Page {tradesData[backtest.id].page} of {tradesData[backtest.id].total_pages}</span>
<button class="btn-pagination" onclick={() => nextTradesPage(backtest.id)} disabled={!tradesData[backtest.id].has_next}>Next →</button>
</div>
{/if}
</div>
<div class="trades-list">
{#each tradesData[backtest.id].trades as trade}
<div class="trade-item">
<span class="trade-type" class:buy={trade.type === 'buy'} class:sell={trade.type === 'sell'}>
{trade.type.toUpperCase()}
</span>
<span class="trade-price">${trade.price?.toFixed(6)}</span>
<span class="trade-amount">${trade.amount?.toFixed(2)}</span>
<span class="trade-reason">{trade.exit_reason || 'entry'}</span>
</div>
{/each}
</div>
{:else}
<div class="trades-loading">Loading trades...</div>
{/if}
</div>
{/if}
{/if}
{/if}
{#if backtest.status === 'running'}
<div class="progress-container">
<div class="progress-bar">
<div class="progress-fill" style="width: {backtest.progress ?? 0}%"></div>
</div>
<span class="progress-text">{backtest.progress ?? 0}%</span>
</div>
<button onclick={() => stopBacktest(backtest.id)} class="btn btn-danger">Stop</button>
{/if}
</div>
{/each}
</div>
{/if}
{#if selectedBacktest}
<section class="chart-section">
<div class="chart-header">
<h2>Portfolio Performance</h2>
<button class="close-btn" onclick={() => selectedBacktest = null}>×</button>
</div>
<BacktestChart results={selectedBacktest.result} />
</section>
{/if}
</div>
</main>
<style>
:global(body) {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f0f0f;
color: #fff;
}
main {
min-height: 100vh;
max-width: 900px;
margin: 0 auto;
padding: 2rem;
}
header {
margin-bottom: 2rem;
}
.header-left {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.back-link {
color: #667eea;
text-decoration: none;
font-size: 0.9rem;
}
h1 {
margin: 0;
font-size: 1.75rem;
}
h2 {
font-size: 1.25rem;
margin: 0;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.section-header h2 {
margin: 0;
}
.btn-refresh {
padding: 0.5rem 1rem;
background: rgba(255, 255, 255, 0.1);
color: white;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
font-size: 0.85rem;
cursor: pointer;
width: auto;
}
.btn-refresh:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.15);
transform: none;
}
.btn-refresh:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-sm {
padding: 0.4rem 0.75rem;
font-size: 0.85rem;
}
/* Trades Modal */
.trades-modal {
max-width: 800px;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.trades-modal .modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.trades-modal h3 {
margin: 0;
color: #667eea;
}
.debug-info {
background: yellow;
color: black;
padding: 0.5rem;
margin-bottom: 1rem;
font-family: monospace;
}
.trades-table-wrapper {
overflow-y: auto;
flex: 1;
}
.trades-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.trades-table th,
.trades-table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.trades-table th {
background: rgba(255, 255, 255, 0.05);
font-weight: 600;
color: #ccc;
position: sticky;
top: 0;
}
.trades-table td {
color: #fff;
}
.trade-type {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-weight: 600;
font-size: 0.8rem;
}
.trade-type.buy {
background: rgba(76, 175, 80, 0.2);
color: #4caf50;
}
.trade-type.sell {
background: rgba(244, 67, 54, 0.2);
color: #f44336;
}
.content {
display: grid;
gap: 2rem;
}
section {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 1.5rem;
}
.error {
background: rgba(239, 68, 68, 0.2);
border: 1px solid #ef4444;
color: #fca5a5;
padding: 0.75rem;
border-radius: 8px;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.backtest-error {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #fca5a5;
padding: 0.75rem;
border-radius: 8px;
font-size: 0.85rem;
margin-bottom: 0.75rem;
}
.error-label {
font-weight: 600;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.token-display {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.75rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
}
.token-name {
font-weight: 600;
color: #667eea;
}
.token-address {
font-size: 0.8rem;
color: #888;
font-family: 'Monaco', 'Menlo', monospace;
}
label {
font-size: 0.9rem;
color: #ccc;
}
input, select {
padding: 0.75rem;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.05);
color: #fff;
font-size: 1rem;
}
input:focus, select:focus {
outline: none;
border-color: #667eea;
}
button {
width: 100%;
padding: 0.875rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: transform 0.2s;
}
button:hover:not(:disabled) {
transform: translateY(-2px);
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.empty-state {
color: #888;
text-align: center;
padding: 2rem;
}
.backtest-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.backtest-card {
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
padding: 1rem;
}
/* Inline Trades */
.trades-inline {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.trades-inline h4 {
margin: 0 0 0.5rem;
font-size: 0.9rem;
color: #667eea;
}
.trades-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.trade-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem;
background: rgba(255, 255, 255, 0.03);
border-radius: 6px;
font-size: 0.85rem;
}
.trade-item .trade-type {
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-weight: 600;
font-size: 0.75rem;
}
.trade-item .trade-type.buy {
background: rgba(76, 175, 80, 0.2);
color: #4caf50;
}
.trade-item .trade-type.sell {
background: rgba(244, 67, 54, 0.2);
color: #f44336;
}
.trade-price {
color: #ccc;
font-family: monospace;
}
.trade-amount {
color: #888;
}
.trade-reason {
color: #666;
font-size: 0.8rem;
margin-left: auto;
}
.btn-toggle-trades {
margin-top: 0.75rem;
padding: 0.5rem 1rem;
background: rgba(102, 126, 234, 0.1);
border: 1px solid rgba(102, 126, 234, 0.3);
border-radius: 6px;
color: #667eea;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s;
}
.btn-toggle-trades:hover {
background: rgba(102, 126, 234, 0.2);
}
.backtest-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.backtest-status {
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
}
.status-running {
background: rgba(59, 130, 246, 0.2);
color: #60a5fa;
}
.status-completed {
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
}
.status-failed {
background: rgba(239, 68, 68, 0.2);
color: #fca5a5;
}
.status-stopped {
background: rgba(251, 191, 36, 0.2);
color: #fbbf24;
}
.backtest-date {
color: #888;
font-size: 0.85rem;
}
.backtest-results {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
}
.backtest-config {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin-top: 0.75rem;
padding: 0.5rem 0;
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.config-item {
font-size: 0.8rem;
color: #888;
}
.config-label {
color: #666;
}
.result-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.result-label {
font-size: 0.75rem;
color: #888;
}
.result-value {
font-size: 1.1rem;
font-weight: 500;
}
.positive {
color: #22c55e;
}
.negative {
color: #ef4444;
}
.progress-container {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.progress-bar {
flex: 1;
height: 8px;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
transition: width 0.3s ease;
}
.progress-text {
font-size: 0.85rem;
color: #888;
min-width: 40px;
}
.btn-danger {
margin-top: 0.75rem;
width: auto;
padding: 0.5rem 1rem;
background: rgba(239, 68, 68, 0.2);
color: #fca5a5;
border: 1px solid rgba(239, 68, 68, 0.4);
}
.chart-section {
padding: 1.5rem;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.chart-header h2 {
margin: 0;
}
.close-btn {
width: auto;
padding: 0.25rem 0.75rem;
background: rgba(255, 255, 255, 0.1);
border: none;
color: #888;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
border-radius: 4px;
}
.close-btn:hover {
background: rgba(255, 255, 255, 0.2);
color: #fff;
}
/* Pagination styles */
.trades-pagination-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.trades-count {
font-size: 0.85rem;
color: #888;
}
.pagination-controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
.btn-pagination {
width: auto;
padding: 0.35rem 0.75rem;
background: rgba(102, 126, 234, 0.1);
border: 1px solid rgba(102, 126, 234, 0.3);
border-radius: 6px;
color: #667eea;
font-size: 0.8rem;
cursor: pointer;
transition: all 0.2s;
}
.btn-pagination:hover:not(:disabled) {
background: rgba(102, 126, 234, 0.2);
transform: none;
}
.btn-pagination:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.page-indicator {
font-size: 0.8rem;
color: #888;
min-width: 80px;
text-align: center;
}
.trades-loading {
text-align: center;
color: #888;
padding: 1rem;
font-size: 0.9rem;
}
</style>

View File

@@ -0,0 +1,530 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { isAuthenticated, isLoading, currentBotStore, setCurrentBot, simulationStore, setCurrentSimulation, addSignals, clearSignals, setSimulationLoading, setSimulationError } from '$lib/stores';
import { api } from '$lib/api';
import { SignalChart, TradeDashboard, PortfolioSummary } from '$lib/components';
let botId = $derived($page.params.id);
let tokenName = $state('');
let tokenAddress = $state('');
let klineInterval = $state('1m');
let isRunning = $state(false);
let isRefreshing = $state(false);
onMount(async () => {
if (!$isAuthenticated && !$isLoading) {
goto('/login');
return;
}
if ($isAuthenticated && botId) {
await loadBot();
await loadSimulations();
}
});
async function loadBot() {
try {
const bot = await api.bots.get(botId);
setCurrentBot(bot);
// Extract token info from strategy config
const strategy = bot.strategy_config;
if (strategy) {
const condition = strategy.conditions?.[0];
const action = strategy.actions?.[0];
tokenName = condition?.token || action?.token || '';
tokenAddress = condition?.token_address || action?.token_address || '';
}
} catch (e) {
goto('/dashboard');
}
}
async function loadSimulations() {
isRefreshing = true;
try {
const simulations = await api.simulate.list(botId);
// Find the most recent running simulation, or fall back to most recent
let current = simulations.find(s => s.status === 'running') || simulations[0];
if (current) {
setCurrentSimulation(current);
clearSignals();
if (current.signals && current.signals.length > 0) {
addSignals(current.signals);
}
isRunning = current.status === 'running';
}
} catch (e) {
console.error('Failed to load simulations:', e);
} finally {
isRefreshing = false;
}
}
async function startSimulation() {
setSimulationError(null);
setSimulationLoading(true);
isRunning = true;
try {
const simulation = await api.simulate.start(botId, {
token: tokenAddress,
chain: 'bsc',
kline_interval: klineInterval
});
setCurrentSimulation(simulation);
clearSignals();
} catch (e) {
setSimulationError(e instanceof Error ? e.message : 'Failed to start simulation');
isRunning = false;
} finally {
setSimulationLoading(false);
}
}
async function stopSimulation() {
if (!$simulationStore.currentSimulation) return;
try {
await api.simulate.stop(botId, $simulationStore.currentSimulation.id);
await loadSimulations();
isRunning = false;
} catch (e) {
console.error('Failed to stop simulation:', e);
}
}
</script>
<svelte:head>
<title>Simulate - {$currentBotStore?.name || 'Bot'} - Randebu</title>
</svelte:head>
<main>
<header>
<div class="header-left">
<a href="/bot/{botId}" class="back-link">← Back to Chat</a>
<h1>Simulation</h1>
</div>
<div class="header-right">
{#if $simulationStore.currentSimulation}
<button
type="button"
class="refresh-btn"
onclick={() => loadSimulations()}
class:refreshing={isRefreshing}
>
{isRefreshing ? '⟳ Refreshing...' : '⟳ Refresh'}
</button>
{/if}
</div>
</header>
<div class="notice">
<span class="notice-icon">⚠️</span>
<span>Simulation Mode - Running on {klineInterval} kline data. Stop simulation to see results.</span>
</div>
<div class="content">
<section class="config-section">
<h2>Configure Simulation</h2>
{#if $simulationStore.error}
<div class="error">{$simulationStore.error}</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); startSimulation(); }}>
<div class="form-row">
<div class="field token-info">
<label>Token</label>
<div class="token-display">
<span class="token-name">{tokenName || 'Not configured'}</span>
{#if tokenAddress}
<span class="token-address">{tokenAddress.slice(0, 10)}...{tokenAddress.slice(-8)}</span>
{/if}
</div>
</div>
<div class="field">
<label for="klineInterval">Kline Interval</label>
<select id="klineInterval" bind:value={klineInterval} disabled={isRunning}>
<option value="1m">1 minute</option>
<option value="5m">5 minutes</option>
<option value="15m">15 minutes</option>
<option value="1h">1 hour</option>
</select>
</div>
</div>
{#if isRunning}
<button type="button" onclick={stopSimulation} class="btn btn-danger">
Stop Simulation
</button>
{:else}
<button type="submit" disabled={$simulationStore.isLoading}>
{$simulationStore.isLoading ? 'Starting...' : 'Start Simulation'}
</button>
{/if}
</form>
</section>
<section class="signals-section">
<h2>Portfolio</h2>
<PortfolioSummary
initialBalance={$simulationStore.currentSimulation?.portfolio?.initial_balance || 10000}
currentBalance={$simulationStore.currentSimulation?.portfolio?.current_balance || 10000}
position={$simulationStore.currentSimulation?.portfolio?.position || 0}
positionToken={$simulationStore.currentSimulation?.portfolio?.position_token || ''}
entryPrice={$simulationStore.currentSimulation?.portfolio?.entry_price || 0}
currentPrice={$simulationStore.currentSimulation?.portfolio?.current_price || 0}
/>
<h2 style="margin-top: 1.5rem;">Price Chart</h2>
<SignalChart signals={$simulationStore.signals} klines={$simulationStore.currentSimulation?.klines || []} height={250} />
<h2 style="margin-top: 1.5rem;">Trade Activity</h2>
<TradeDashboard tradeLog={$simulationStore.currentSimulation?.trade_log || []} />
<h2 style="margin-top: 1.5rem;">Signals ({$simulationStore.signals.length})</h2>
{#if $simulationStore.signals.length === 0}
<p class="empty-state">No signals generated. The chart above shows price movement.</p>
{:else}
<div class="signals-list">
{#each $simulationStore.signals as signal}
<div class="signal-card">
<div class="signal-header">
<span class="signal-type type-{signal.signal_type}">{signal.signal_type}</span>
<span class="signal-token">{signal.token}</span>
<span class="signal-price">${signal.price.toFixed(6)}</span>
</div>
{#if signal.confidence}
<div class="signal-confidence">
<span>Confidence: {(signal.confidence * 100).toFixed(1)}%</span>
<div class="confidence-bar">
<div class="confidence-fill" style="width: {signal.confidence * 100}%"></div>
</div>
</div>
{/if}
{#if signal.reasoning}
<p class="signal-reasoning">{signal.reasoning}</p>
{/if}
<div class="signal-time">
{new Date(signal.created_at).toLocaleString()}
</div>
</div>
{/each}
</div>
{/if}
</section>
</div>
</main>
<style>
:global(body) {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f0f0f;
color: #fff;
}
main {
min-height: 100vh;
max-width: 900px;
margin: 0 auto;
padding: 2rem;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.header-right {
display: flex;
gap: 0.5rem;
}
.refresh-btn {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff;
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
display: flex;
align-items: center;
gap: 0.5rem;
transition: all 0.2s;
}
.refresh-btn:hover {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.3);
}
.refresh-btn.refreshing {
opacity: 0.7;
cursor: not-allowed;
}
header {
margin-bottom: 1.5rem;
}
.header-left {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.back-link {
color: #667eea;
text-decoration: none;
font-size: 0.9rem;
}
h1 {
margin: 0;
font-size: 1.75rem;
}
.notice {
display: flex;
align-items: center;
gap: 0.75rem;
background: rgba(251, 191, 36, 0.1);
border: 1px solid rgba(251, 191, 36, 0.3);
border-radius: 8px;
padding: 0.75rem 1rem;
margin-bottom: 1.5rem;
font-size: 0.9rem;
color: #fbbf24;
}
.notice-icon {
font-size: 1.25rem;
}
.content {
display: grid;
gap: 2rem;
}
section {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 1.5rem;
}
h2 {
font-size: 1.25rem;
margin: 0 0 1rem;
}
.error {
background: rgba(239, 68, 68, 0.2);
border: 1px solid #ef4444;
color: #fca5a5;
padding: 0.75rem;
border-radius: 8px;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.token-display {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.75rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
}
.token-name {
font-weight: 600;
color: #667eea;
}
.token-address {
font-size: 0.8rem;
color: #888;
font-family: 'Monaco', 'Menlo', monospace;
}
.checkbox-field {
flex-direction: row;
align-items: center;
margin-bottom: 1rem;
}
.checkbox-field input {
width: auto;
margin-right: 0.5rem;
}
.checkbox-field label {
margin: 0;
}
label {
font-size: 0.9rem;
color: #ccc;
}
input, select {
padding: 0.75rem;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.05);
color: #fff;
font-size: 1rem;
}
input:focus, select:focus {
outline: none;
border-color: #667eea;
}
button {
width: 100%;
padding: 0.875rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: transform 0.2s;
}
button:hover:not(:disabled) {
transform: translateY(-2px);
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-danger {
background: rgba(239, 68, 68, 0.2);
color: #fca5a5;
border: 1px solid rgba(239, 68, 68, 0.4);
}
.empty-state {
color: #888;
text-align: center;
padding: 2rem;
}
.signals-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.signal-card {
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
padding: 1rem;
}
.signal-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 0.5rem;
}
.signal-type {
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
}
.type-buy {
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
}
.type-sell {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.type-hold {
background: rgba(251, 191, 36, 0.2);
color: #fbbf24;
}
.signal-token {
font-weight: 500;
}
.signal-price {
color: #888;
font-size: 0.9rem;
}
.signal-confidence {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
font-size: 0.85rem;
color: #888;
}
.confidence-bar {
flex: 1;
height: 4px;
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
}
.confidence-fill {
height: 100%;
background: linear-gradient(90deg, #667eea, #764ba2);
border-radius: 2px;
}
.signal-reasoning {
font-size: 0.9rem;
color: #ccc;
margin: 0.5rem 0;
line-height: 1.4;
}
.signal-time {
font-size: 0.75rem;
color: #666;
}
</style>

View File

@@ -0,0 +1,291 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { isAuthenticated, isLoading, botsStore, setBots, addBot, removeBot, userStore, logout } from '$lib/stores';
import { api } from '$lib/api';
import { BotCard } from '$lib/components';
let showCreateModal = $state(false);
let newBotName = $state('');
let newBotDescription = $state('');
let isCreating = $state(false);
let createError = $state('');
onMount(async () => {
if (!$isAuthenticated && !$isLoading) {
goto('/login');
return;
}
if ($isAuthenticated) {
await loadBots();
}
});
async function loadBots() {
try {
const bots = await api.bots.list();
setBots(bots);
} catch (e) {
console.error('Failed to load bots:', e);
}
}
async function createBot() {
if (!newBotName.trim()) return;
createError = '';
isCreating = true;
try {
const bot = await api.bots.create(newBotName, newBotDescription);
addBot(bot);
showCreateModal = false;
newBotName = '';
newBotDescription = '';
goto(`/bot/${bot.id}`);
} catch (e) {
createError = e instanceof Error ? e.message : 'Failed to create bot';
} finally {
isCreating = false;
}
}
async function deleteBot(botId: string) {
if (!confirm('Are you sure you want to delete this bot?')) return;
try {
await api.bots.delete(botId);
removeBot(botId);
} catch (e) {
console.error('Failed to delete bot:', e);
}
}
function handleLogout() {
logout();
goto('/');
}
</script>
<svelte:head>
<title>Dashboard - Randebu</title>
</svelte:head>
<main>
<header>
<h1>Dashboard</h1>
<div class="header-actions">
<span class="user-email">{$userStore?.email}</span>
<a href="/settings" class="btn btn-secondary">Settings</a>
<button onclick={handleLogout} class="btn btn-secondary">Logout</button>
</div>
</header>
<section class="bots-section">
<div class="section-header">
<h2>Your Bots ({$botsStore.length}/3)</h2>
{#if $botsStore.length < 3}
<button onclick={() => showCreateModal = true} class="btn btn-primary">
Create New Bot
</button>
{/if}
</div>
{#if $botsStore.length === 0}
<div class="empty-state">
<p>You haven't created any bots yet.</p>
<p>Create your first bot to start trading!</p>
</div>
{:else}
<div class="bots-grid">
{#each $botsStore as bot}
<BotCard {bot} onOpen={(id) => goto(`/bot/${id}`)} onDelete={deleteBot} />
{/each}
</div>
{/if}
</section>
{#if showCreateModal}
<div class="modal-overlay" onclick={() => showCreateModal = false}>
<div class="modal" onclick={(e) => e.stopPropagation()}>
<h2>Create New Bot</h2>
{#if createError}
<div class="error">{createError}</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); createBot(); }}>
<div class="field">
<label for="botName">Bot Name</label>
<input type="text" id="botName" bind:value={newBotName} required />
</div>
<div class="field">
<label for="botDescription">Description (optional)</label>
<textarea id="botDescription" bind:value={newBotDescription} rows="3"></textarea>
</div>
<div class="modal-actions">
<button type="button" onclick={() => showCreateModal = false} class="btn btn-secondary">
Cancel
</button>
<button type="submit" disabled={isCreating}>
{isCreating ? 'Creating...' : 'Create'}
</button>
</div>
</form>
</div>
</div>
{/if}
</main>
<style>
:global(body) {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f0f0f;
color: #fff;
}
main {
min-height: 100vh;
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
h1 {
margin: 0;
font-size: 2rem;
}
.header-actions {
display: flex;
align-items: center;
gap: 1rem;
}
.user-email {
color: #888;
font-size: 0.9rem;
}
h2 {
margin: 0;
font-size: 1.25rem;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.bots-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
}
.btn {
padding: 0.5rem 1rem;
border-radius: 8px;
text-decoration: none;
font-weight: 500;
font-size: 0.9rem;
cursor: pointer;
border: none;
transition: transform 0.2s, opacity 0.2s;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-secondary {
background: rgba(255, 255, 255, 0.1);
color: white;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.btn:hover {
transform: translateY(-2px);
}
.empty-state {
text-align: center;
padding: 3rem;
color: #888;
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal {
background: #1a1a1a;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 2rem;
width: 100%;
max-width: 450px;
}
.modal h2 {
margin: 0 0 1.5rem;
}
.error {
background: rgba(239, 68, 68, 0.2);
border: 1px solid #ef4444;
color: #fca5a5;
padding: 0.75rem;
border-radius: 8px;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.field {
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 0.5rem;
color: #ccc;
font-size: 0.9rem;
}
input, textarea {
width: 100%;
padding: 0.75rem;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.05);
color: #fff;
font-size: 1rem;
box-sizing: border-box;
}
input:focus, textarea:focus {
outline: none;
border-color: #667eea;
}
.modal-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 1.5rem;
}
</style>

View File

@@ -0,0 +1,176 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { login, isAuthenticated } from '$lib/stores';
let email = $state('');
let password = $state('');
let error = $state('');
let isLoading = $state(false);
$effect(() => {
if ($isAuthenticated) {
goto('/dashboard');
}
});
async function handleSubmit() {
error = '';
isLoading = true;
try {
await login(email, password);
goto('/dashboard');
} catch (e) {
error = e instanceof Error ? e.message : 'Login failed';
} finally {
isLoading = false;
}
}
</script>
<svelte:head>
<title>Login - Randebu</title>
</svelte:head>
<main>
<div class="auth-card">
<h1>Login</h1>
<p class="subtitle">Welcome back</p>
{#if error}
<div class="error">{error}</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
<div class="field">
<label for="email">Email</label>
<input type="email" id="email" bind:value={email} required />
</div>
<div class="field">
<label for="password">Password</label>
<input type="password" id="password" bind:value={password} required />
</div>
<button type="submit" disabled={isLoading}>
{isLoading ? 'Logging in...' : 'Login'}
</button>
</form>
<p class="footer">
Don't have an account? <a href="/register">Register</a>
</p>
</div>
</main>
<style>
:global(body) {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f0f0f;
color: #fff;
}
main {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.auth-card {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 2.5rem;
width: 100%;
max-width: 400px;
}
h1 {
margin: 0;
font-size: 1.75rem;
text-align: center;
}
.subtitle {
color: #888;
text-align: center;
margin: 0.5rem 0 2rem;
}
.error {
background: rgba(239, 68, 68, 0.2);
border: 1px solid #ef4444;
color: #fca5a5;
padding: 0.75rem;
border-radius: 8px;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.field {
margin-bottom: 1.25rem;
}
label {
display: block;
margin-bottom: 0.5rem;
color: #ccc;
font-size: 0.9rem;
}
input {
width: 100%;
padding: 0.75rem;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.05);
color: #fff;
font-size: 1rem;
box-sizing: border-box;
}
input:focus {
outline: none;
border-color: #667eea;
}
button {
width: 100%;
padding: 0.875rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: transform 0.2s;
}
button:hover:not(:disabled) {
transform: translateY(-2px);
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.footer {
text-align: center;
margin-top: 1.5rem;
color: #888;
font-size: 0.9rem;
}
.footer a {
color: #667eea;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
</style>

View File

@@ -0,0 +1,193 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { register, isAuthenticated } from '$lib/stores';
let email = $state('');
let password = $state('');
let confirmPassword = $state('');
let error = $state('');
let isLoading = $state(false);
$effect(() => {
if ($isAuthenticated) {
goto('/dashboard');
}
});
async function handleSubmit() {
error = '';
if (password !== confirmPassword) {
error = 'Passwords do not match';
return;
}
if (password.length < 6) {
error = 'Password must be at least 6 characters';
return;
}
isLoading = true;
try {
await register(email, password);
goto('/dashboard');
} catch (e) {
error = e instanceof Error ? e.message : 'Registration failed';
} finally {
isLoading = false;
}
}
</script>
<svelte:head>
<title>Register - Randebu</title>
</svelte:head>
<main>
<div class="auth-card">
<h1>Create Account</h1>
<p class="subtitle">Start creating trading bots</p>
{#if error}
<div class="error">{error}</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
<div class="field">
<label for="email">Email</label>
<input type="email" id="email" bind:value={email} required />
</div>
<div class="field">
<label for="password">Password</label>
<input type="password" id="password" bind:value={password} required minlength="6" />
</div>
<div class="field">
<label for="confirmPassword">Confirm Password</label>
<input type="password" id="confirmPassword" bind:value={confirmPassword} required />
</div>
<button type="submit" disabled={isLoading}>
{isLoading ? 'Creating account...' : 'Create Account'}
</button>
</form>
<p class="footer">
Already have an account? <a href="/login">Login</a>
</p>
</div>
</main>
<style>
:global(body) {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f0f0f;
color: #fff;
}
main {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.auth-card {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 2.5rem;
width: 100%;
max-width: 400px;
}
h1 {
margin: 0;
font-size: 1.75rem;
text-align: center;
}
.subtitle {
color: #888;
text-align: center;
margin: 0.5rem 0 2rem;
}
.error {
background: rgba(239, 68, 68, 0.2);
border: 1px solid #ef4444;
color: #fca5a5;
padding: 0.75rem;
border-radius: 8px;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.field {
margin-bottom: 1.25rem;
}
label {
display: block;
margin-bottom: 0.5rem;
color: #ccc;
font-size: 0.9rem;
}
input {
width: 100%;
padding: 0.75rem;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.05);
color: #fff;
font-size: 1rem;
box-sizing: border-box;
}
input:focus {
outline: none;
border-color: #667eea;
}
button {
width: 100%;
padding: 0.875rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: transform 0.2s;
}
button:hover:not(:disabled) {
transform: translateY(-2px);
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.footer {
text-align: center;
margin-top: 1.5rem;
color: #888;
font-size: 0.9rem;
}
.footer a {
color: #667eea;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
</style>

View File

@@ -0,0 +1,281 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { isAuthenticated, isLoading, userStore, logout } from '$lib/stores';
import { api } from '$lib/api';
let email = $state('');
let currentPassword = $state('');
let newPassword = $state('');
let confirmPassword = $state('');
let isUpdating = $state(false);
let updateSuccess = $state('');
let updateError = $state('');
onMount(async () => {
if (!$isAuthenticated && !$isLoading) {
goto('/login');
return;
}
if ($userStore) {
email = $userStore.email;
}
});
async function updateEmail() {
updateSuccess = '';
updateError = '';
isUpdating = true;
try {
await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8000/api'}/auth/settings`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({ email })
});
updateSuccess = 'Email updated successfully';
} catch (e) {
updateError = e instanceof Error ? e.message : 'Failed to update email';
} finally {
isUpdating = false;
}
}
async function updatePassword() {
updateSuccess = '';
updateError = '';
if (newPassword !== confirmPassword) {
updateError = 'Passwords do not match';
return;
}
if (newPassword.length < 6) {
updateError = 'Password must be at least 6 characters';
return;
}
isUpdating = true;
try {
await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8000/api'}/auth/settings`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({ password: newPassword, current_password: currentPassword })
});
updateSuccess = 'Password updated successfully';
currentPassword = '';
newPassword = '';
confirmPassword = '';
} catch (e) {
updateError = e instanceof Error ? e.message : 'Failed to update password';
} finally {
isUpdating = false;
}
}
function handleLogout() {
logout();
goto('/');
}
</script>
<svelte:head>
<title>Settings - Randebu</title>
</svelte:head>
<main>
<header>
<div class="header-left">
<a href="/dashboard" class="back-link">← Dashboard</a>
<h1>Settings</h1>
</div>
</header>
<div class="content">
<section class="settings-section">
<h2>Profile</h2>
{#if updateSuccess}
<div class="success">{updateSuccess}</div>
{/if}
{#if updateError}
<div class="error">{updateError}</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); updateEmail(); }}>
<div class="field">
<label for="email">Email</label>
<input type="email" id="email" bind:value={email} required />
</div>
<button type="submit" disabled={isUpdating}>
{isUpdating ? 'Updating...' : 'Update Email'}
</button>
</form>
</section>
<section class="settings-section">
<h2>Change Password</h2>
<form onsubmit={(e) => { e.preventDefault(); updatePassword(); }}>
<div class="field">
<label for="currentPassword">Current Password</label>
<input type="password" id="currentPassword" bind:value={currentPassword} required />
</div>
<div class="field">
<label for="newPassword">New Password</label>
<input type="password" id="newPassword" bind:value={newPassword} required minlength="6" />
</div>
<div class="field">
<label for="confirmPassword">Confirm New Password</label>
<input type="password" id="confirmPassword" bind:value={confirmPassword} required />
</div>
<button type="submit" disabled={isUpdating}>
{isUpdating ? 'Updating...' : 'Update Password'}
</button>
</form>
</section>
<section class="settings-section danger-section">
<h2>Account</h2>
<button onclick={handleLogout} class="btn btn-danger">
Logout
</button>
</section>
</div>
</main>
<style>
:global(body) {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f0f0f;
color: #fff;
}
main {
min-height: 100vh;
max-width: 600px;
margin: 0 auto;
padding: 2rem;
}
header {
margin-bottom: 2rem;
}
.header-left {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.back-link {
color: #667eea;
text-decoration: none;
font-size: 0.9rem;
}
h1 {
margin: 0;
font-size: 1.75rem;
}
.content {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
section {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 1.5rem;
}
h2 {
font-size: 1.1rem;
margin: 0 0 1rem;
}
.success {
background: rgba(34, 197, 94, 0.2);
border: 1px solid #22c55e;
color: #86efac;
padding: 0.75rem;
border-radius: 8px;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.error {
background: rgba(239, 68, 68, 0.2);
border: 1px solid #ef4444;
color: #fca5a5;
padding: 0.75rem;
border-radius: 8px;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.field {
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 0.5rem;
color: #ccc;
font-size: 0.9rem;
}
input {
width: 100%;
padding: 0.75rem;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.05);
color: #fff;
font-size: 1rem;
box-sizing: border-box;
}
input:focus {
outline: none;
border-color: #667eea;
}
button {
width: 100%;
padding: 0.875rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: transform 0.2s;
}
button:hover:not(:disabled) {
transform: translateY(-2px);
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.danger-section button {
background: rgba(239, 68, 68, 0.2);
color: #fca5a5;
border: 1px solid rgba(239, 68, 68, 0.4);
}
</style>

View File

@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

View File

@@ -0,0 +1,17 @@
import adapter from '@sveltejs/adapter-auto';
/** @type {import('@sveltejs/kit').Config} */
const config = {
compilerOptions: {
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true)
},
kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter()
}
};
export default config;

View File

@@ -0,0 +1,20 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}

View File

@@ -0,0 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
});