Compare commits

..

143 Commits

Author SHA1 Message Date
shokollm
f46cad4379 WIP: Multiple fixes and improvements
Backend:
- Fixed auth issue where get_optional_user wasn't properly extracting tokens
- Added user_id to conversational agent for proper auth context
- Fixed DCA buy logic to support multiple buys on dips
- Fixed sell logic to use amount_percent
- Added comprehensive backtest engine tests
- Fixed kline data validation for bad price data
- Fixed chained tool calls handling

Frontend:
- BotCard now links to chat page instead of bot page
- Chat page handles direct bot loading from dashboard
- Various UI improvements and fixes

Tests:
- Added test_agent.py with mock client tests
- Added test_backtest_engine.py with 7 comprehensive tests
2026-04-15 15:20:42 +00:00
shokollm
1b8761d1f4 feat: frontend conversation-based chat UI (#60)
- Add Conversation, Message, ConversationWithMessages types
- Add conversations API client methods (list, create, get, delete, chat, setBot)
- Create conversationStore for state management
- Create ChatLayout component with left/right panes
- Create ConversationList component for left pane
- Create ChatArea component for messages display
- Create ChatInput component for message input
- Create BotInfoPanel component for right pane
- Create AnonymousBanner component for non-logged users
- Create CandlestickLoader animation component
- Add /home and /chat routes
- Handle anonymous user rate limits (40 warning, 50 max)
- Handle system rate limits (429) and auth limits (403)
2026-04-14 06:36:55 +00:00
958dc3bb1f Merge pull request 'feat: conversation-based chat system with anonymous support' (#65) from fix/issue-59 into main 2026-04-14 08:19:54 +02:00
shokollm
5ae8d76bde feat: implement conversation-based chat system with anonymous support
- Add Conversation model with user/anonymous_token/bot_id fields
- Add Message model linked to conversations
- Add AnonymousUser model for tracking anonymous chat limits
- Create /api/conversations endpoints (list, create, get, delete)
- Add POST /api/conversations/{id}/chat for messaging
- Add POST /api/conversations/{id}/set-bot for linking bot
- Implement rate limiter with system-wide (500/5hrs) and anonymous limits
- Anonymous users: max 50 chats, max 1 bot, max 1 backtest
- Add warning after 40 anonymous messages
- Register conversations router in main.py
- Add create_bot, list_bots, set_bot, get_bot_info tools to registry
2026-04-14 04:25:00 +00:00
a9679bbb5d Merge pull request 'refactor: Split conversational.py into modular structure' (#64) from fix/issue-63 into main 2026-04-14 05:57:16 +02:00
shokollm
b1ddad0808 Fix intermittent UnboundLocalError for 'thinking' variable in ConversationalAgent.chat() - initialize thinking=None before conditional assignment to handle API responses missing message field 2026-04-14 03:34:36 +00:00
shokollm
f705269e34 refactor: Split conversational.py into modular structure (fixes #63)
Split conversational.py (2271 lines) into modular files:
- tools.py: TOOL_REGISTRY, get_tool_registry(), SKILL_EMOJIS
- help.py: format_* functions for slash command help
- client.py: MiniMaxClient, SYSTEM_PROMPT, TOOLS definitions
- agent.py: ConversationalAgent class with all methods
- __init__.py: Public exports from all modules

Updated bots.py import to use new module path.
Deleted conversational.py.
2026-04-14 02:36:23 +00:00
8acce849f4 Merge pull request 'feat: Add slash command help system (#57)' (#62) from fix/issue-57 into main 2026-04-14 04:03:29 +02:00
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
64 changed files with 11589 additions and 543 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

View File

@@ -34,6 +34,9 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade; proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
proxy_send_timeout 300s;
} }
location /ws/ { location /ws/ {

View File

@@ -10,6 +10,8 @@ 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 ExecStart=/var/www/bot/src/backend/venv/bin/python /var/www/bot/src/backend/run.py
Restart=always Restart=always
RestartSec=10 RestartSec=10
TimeoutStartSec=300
TimeoutStopSec=300
EnvironmentFile=/var/www/bot/data/.env EnvironmentFile=/var/www/bot/data/.env

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*

View File

@@ -32,7 +32,7 @@ MINIMAX_API_KEY=your-minimax-api-key
# MiniMax model to use # MiniMax model to use
# Common options: MiniMax-Text-01, MiniMax-M2.1 # Common options: MiniMax-Text-01, MiniMax-M2.1
MINIMAX_MODEL=MiniMax-Text-01 MINIMAX_MODEL=MiniMax-M2.7
# ============================================================================= # =============================================================================
# AVE CLOUD API # AVE CLOUD API

View File

@@ -1,7 +1,7 @@
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, OAuth2PasswordRequestForm
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import Annotated from typing import Optional, Annotated
from ..core.database import get_db from ..core.database import get_db
from ..core.security import ( from ..core.security import (
@@ -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,
@@ -25,6 +26,14 @@ router = APIRouter()
settings = get_settings() settings = get_settings()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
# Custom optional token extractor that doesn't raise on missing token
def get_optional_token(request: Request) -> Optional[str]:
"""Extract bearer token from Authorization header, returning None if not present."""
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
return auth_header[7:] # Remove "Bearer " prefix
return None
TOKEN_BLACKLIST = set() TOKEN_BLACKLIST = set()
@@ -57,8 +66,33 @@ def get_current_user(
return user return user
def get_optional_user(
request: Request,
db: Session = Depends(get_db),
) -> Optional[User]:
"""Get current user, returning None if not authenticated."""
token = get_optional_token(request)
if not token:
return None
if token in TOKEN_BLACKLIST:
return None
payload = verify_token(token)
if payload is None:
return None
user_id = payload.get("sub")
if user_id is None:
return None
user = db.query(User).filter(User.id == user_id).first()
return 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 +109,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",

View File

@@ -1,16 +1,17 @@
import uuid import uuid
import asyncio import asyncio
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional, Union
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from .auth import get_current_user from .auth import get_optional_user, get_current_user
from ..core.database import get_db from ..core.database import get_db
from ..core.config import get_settings 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 from ..db.models import Bot, Backtest, Signal, User, AnonymousUser
from ..services.rate_limiter import RateLimiter
router = APIRouter() router = APIRouter()
@@ -22,6 +23,7 @@ def run_backtest_sync(
backtest_id: str, db_url: str, bot_id: str, config: Dict[str, Any] backtest_id: str, db_url: str, bot_id: str, config: Dict[str, Any]
): ):
import asyncio import asyncio
import json
from ..services.backtest.engine import BacktestEngine from ..services.backtest.engine import BacktestEngine
from ..core.database import SessionLocal from ..core.database import SessionLocal
@@ -31,6 +33,19 @@ def run_backtest_sync(
running_backtests[backtest_id] = engine running_backtests[backtest_id] = engine
try: try:
results = await engine.run() 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() db = SessionLocal()
try: try:
backtest = db.query(Backtest).filter(Backtest.id == backtest_id).first() backtest = db.query(Backtest).filter(Backtest.id == backtest_id).first()
@@ -41,17 +56,18 @@ def run_backtest_sync(
db.commit() db.commit()
for signal in engine.signals: for signal in engine.signals:
signal_data = convert_datetime(signal)
db_signal = Signal( db_signal = Signal(
id=signal["id"], id=signal_data["id"],
bot_id=signal["bot_id"], bot_id=signal_data["bot_id"],
run_id=signal["run_id"], run_id=signal_data["run_id"],
signal_type=signal["signal_type"], signal_type=signal_data["signal_type"],
token=signal["token"], token=signal_data["token"],
price=signal["price"], price=signal_data["price"],
confidence=signal.get("confidence"), confidence=signal_data.get("confidence"),
reasoning=signal.get("reasoning"), reasoning=signal_data.get("reasoning"),
executed=signal.get("executed", False), executed=signal_data.get("executed", False),
created_at=signal["created_at"], created_at=signal["created_at"], # Use original datetime, not converted string
) )
db.add(db_signal) db.add(db_signal)
db.commit() db.commit()
@@ -73,18 +89,41 @@ async def start_backtest(
bot_id: str, bot_id: str,
config: BacktestCreate, config: BacktestCreate,
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user), current_user: Optional[User] = Depends(get_optional_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
request: Request = None,
): ):
bot = db.query(Bot).filter(Bot.id == bot_id).first() bot = db.query(Bot).filter(Bot.id == bot_id).first()
if not bot: if not bot:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Bot not found" status_code=status.HTTP_404_NOT_FOUND, detail="Bot not found"
) )
# Check authorization
if current_user:
# Authenticated user - must own the bot
if bot.user_id != current_user.id: if bot.user_id != current_user.id:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized" status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
) )
else:
# Anonymous user - can only run backtests on anonymous bots (user_id = None)
if bot.user_id is not None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You need to be logged in to run backtests on this bot"
)
# Rate limit anonymous backtests
anonymous_token = request.cookies.get("anonymous_token") if request else None
if anonymous_token:
try:
RateLimiter.check_anonymous_backtest_limit(db, anonymous_token)
except HTTPException as e:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You've reached the maximum of 1 backtest as an anonymous user. Please login or create an account for unlimited backtests."
)
settings = get_settings() settings = get_settings()
backtest_id = str(uuid.uuid4()) backtest_id = str(uuid.uuid4())
@@ -119,6 +158,10 @@ async def start_backtest(
db.commit() db.commit()
db.refresh(backtest) db.refresh(backtest)
# Increment anonymous backtest count
if not current_user and anonymous_token:
RateLimiter.increment_backtest_count(db, anonymous_token)
db_url = str(settings.DATABASE_URL) db_url = str(settings.DATABASE_URL)
background_tasks.add_task( background_tasks.add_task(
run_backtest_sync, backtest_id, db_url, bot_id, backtest_config run_backtest_sync, backtest_id, db_url, bot_id, backtest_config
@@ -154,9 +197,81 @@ def get_backtest(
status_code=status.HTTP_404_NOT_FOUND, detail="Backtest not found" 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 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( def list_backtests(
bot_id: str, bot_id: str,
@@ -177,6 +292,7 @@ def list_backtests(
db.query(Backtest) db.query(Backtest)
.filter(Backtest.bot_id == bot_id) .filter(Backtest.bot_id == bot_id)
.order_by(Backtest.started_at.desc()) .order_by(Backtest.started_at.desc())
.limit(5)
.all() .all()
) )
return backtests return backtests
@@ -211,7 +327,12 @@ def stop_backtest(
if run_id in running_backtests: if run_id in running_backtests:
engine = running_backtests[run_id] engine = running_backtests[run_id]
asyncio.create_task(engine.stop()) 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.status = "stopped"
backtest.ended_at = datetime.utcnow() backtest.ended_at = datetime.utcnow()
db.commit() db.commit()

View File

@@ -1,8 +1,8 @@
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, Annotated from typing import List, Annotated, Optional
from .auth import get_current_user from .auth import get_current_user, get_optional_user
from ..core.database import get_db from ..core.database import get_db
from ..core.config import get_settings from ..core.config import get_settings
from ..db.schemas import ( from ..db.schemas import (
@@ -16,6 +16,7 @@ from ..db.schemas import (
) )
from ..db.models import Bot, BotConversation, User from ..db.models import Bot, BotConversation, User
from ..services.ai_agent.crew import get_trading_crew from ..services.ai_agent.crew import get_trading_crew
from ..services.ai_agent import get_conversational_agent
router = APIRouter() router = APIRouter()
MAX_BOTS_PER_USER = 3 MAX_BOTS_PER_USER = 3
@@ -70,7 +71,7 @@ def create_bot(
@router.get("/{bot_id}", response_model=BotResponse) @router.get("/{bot_id}", response_model=BotResponse)
def get_bot( def get_bot(
bot_id: str, bot_id: str,
current_user: Annotated[User, Depends(get_current_user)], current_user: Optional[User] = Depends(get_optional_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
bot = db.query(Bot).filter(Bot.id == bot_id).first() bot = db.query(Bot).filter(Bot.id == bot_id).first()
@@ -79,11 +80,22 @@ def get_bot(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="Bot not found", detail="Bot not found",
) )
# Check authorization
if current_user:
# Authenticated user - must own the bot
if bot.user_id != current_user.id: if bot.user_id != current_user.id:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to access this bot", detail="Not authorized to access this bot",
) )
else:
# Anonymous user - can only access anonymous bots (user_id = None)
if bot.user_id is not None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to access this bot",
)
return bot return bot
@@ -162,7 +174,7 @@ def delete_bot(
def chat( def chat(
bot_id: str, bot_id: str,
request: BotChatRequest, request: BotChatRequest,
current_user: Annotated[User, Depends(get_current_user)], current_user: Optional[User] = Depends(get_optional_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
bot = db.query(Bot).filter(Bot.id == bot_id).first() bot = db.query(Bot).filter(Bot.id == bot_id).first()
@@ -171,11 +183,21 @@ def chat(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="Bot not found", detail="Bot not found",
) )
# Check authorization
if current_user:
# Authenticated user - must own the bot
if bot.user_id != current_user.id: if bot.user_id != current_user.id:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to chat with this bot", detail="Not authorized to chat with this bot",
) )
else:
# Anonymous user - can only chat with anonymous bots (user_id = None)
if bot.user_id is not None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to chat with this bot",
)
conversation_history = ( conversation_history = (
db.query(BotConversation) db.query(BotConversation)
@@ -183,21 +205,29 @@ def chat(
.order_by(BotConversation.created_at) .order_by(BotConversation.created_at)
.all() .all()
) )
history_for_crew = [ history_for_agent = [
{"role": conv.role, "content": conv.content} {"role": conv.role, "content": conv.content}
for conv in conversation_history[-10:] for conv in conversation_history[-10:]
] ]
user_message = request.message user_message = request.message
if request.strategy_config:
crew = get_trading_crew() import logging
result = crew.chat(user_message, history_for_crew) logger = logging.getLogger(__name__)
logger.warning(f"chat endpoint: current_user={current_user}, user_id={current_user.id if current_user else None}")
# Use ConversationalAgent for natural chat with tool-calling
agent = get_conversational_agent(
bot_id=bot_id,
user_id=current_user.id if current_user else None
)
logger.warning(f"chat endpoint: agent.user_id={agent.user_id}")
result = agent.chat(user_message, history_for_agent)
assistant_content = result.get("response", "I couldn't process your request.") assistant_content = result.get("response", "I couldn't process your request.")
if result.get("success") and result.get("strategy_config"):
bot.strategy_config = result["strategy_config"]
db.commit()
# Save conversation
db_conversation = BotConversation( db_conversation = BotConversation(
bot_id=bot_id, bot_id=bot_id,
role="user", role="user",
@@ -214,44 +244,27 @@ def chat(
db.commit() db.commit()
db.refresh(db_assistant) db.refresh(db_assistant)
return BotChatResponse( # If strategy was updated via tool, refresh bot data
response=assistant_content, if result.get("strategy_updated"):
strategy_config=result.get("strategy_config"), db.refresh(bot)
success=result.get("success", False),
)
else:
crew = get_trading_crew()
result = crew.chat(user_message, history_for_crew)
assistant_content = result.get("response", "I couldn't process your request.")
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)
return BotChatResponse( return BotChatResponse(
response=assistant_content, response=assistant_content,
strategy_config=result.get("strategy_config"), thinking=result.get("thinking"),
strategy_config=bot.strategy_config if result.get("strategy_updated") else None,
success=result.get("success", False), 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.get("/{bot_id}/history", response_model=List[BotConversationResponse]) @router.get("/{bot_id}/history", response_model=List[BotConversationResponse])
def get_history( def get_history(
bot_id: str, bot_id: str,
current_user: Annotated[User, Depends(get_current_user)], current_user: Optional[User] = Depends(get_optional_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
bot = db.query(Bot).filter(Bot.id == bot_id).first() bot = db.query(Bot).filter(Bot.id == bot_id).first()
@@ -260,11 +273,21 @@ def get_history(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="Bot not found", detail="Bot not found",
) )
# Check authorization
if current_user:
# Authenticated user - must own the bot
if bot.user_id != current_user.id: if bot.user_id != current_user.id:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to access this bot's history", detail="Not authorized to access this bot's history",
) )
else:
# Anonymous user - can only access anonymous bots (user_id = None)
if bot.user_id is not None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to access this bot's history",
)
conversations = ( conversations = (
db.query(BotConversation) db.query(BotConversation)

View File

@@ -0,0 +1,350 @@
import secrets
from fastapi import APIRouter, Depends, HTTPException, status, Request, Response
from sqlalchemy.orm import Session
from typing import List, Optional, Annotated
from ..core.database import get_db
from ..db.models import Conversation, Message, User, AnonymousUser, Bot
from ..db.schemas import ChatRequest
from ..api.auth import get_optional_user
from ..services.rate_limiter import RateLimiter
from ..services.ai_agent import get_conversational_agent
router = APIRouter(prefix="/api/conversations", tags=["conversations"])
def get_or_create_anonymous_token(
request: Request, response: Response, db: Session
) -> str:
token = request.cookies.get("anonymous_token")
if not token:
token = secrets.token_urlsafe(32)
response.set_cookie(
key="anonymous_token",
value=token,
max_age=60 * 60 * 24 * 365,
httponly=True,
)
anon = AnonymousUser(id=token)
db.add(anon)
db.commit()
return token
@router.get("")
def list_conversations(
db: Session = Depends(get_db),
current_user: Optional[User] = Depends(get_optional_user),
):
if current_user:
return (
db.query(Conversation)
.filter(Conversation.user_id == current_user.id)
.order_by(Conversation.updated_at.desc())
.all()
)
return []
@router.post("")
def create_conversation(
db: Session = Depends(get_db),
current_user: Optional[User] = Depends(get_optional_user),
request: Request = None,
response: Response = None,
):
anonymous_token = None
if not current_user and request:
anonymous_token = get_or_create_anonymous_token(request, response, db)
conversation = Conversation(
user_id=current_user.id if current_user else None,
anonymous_token=anonymous_token,
)
db.add(conversation)
db.commit()
db.refresh(conversation)
return conversation
@router.get("/{conversation_id}")
def get_conversation(
conversation_id: str,
db: Session = Depends(get_db),
current_user: Optional[User] = Depends(get_optional_user),
):
conversation = (
db.query(Conversation).filter(Conversation.id == conversation_id).first()
)
if not conversation:
raise HTTPException(status_code=404, detail="Conversation not found")
if conversation.user_id and current_user and conversation.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Access denied")
# Get messages for this conversation
messages = (
db.query(Message)
.filter(Message.conversation_id == conversation_id)
.order_by(Message.created_at)
.all()
)
# Build response with messages
return {
"id": conversation.id,
"user_id": conversation.user_id,
"bot_id": conversation.bot_id,
"title": conversation.title,
"created_at": conversation.created_at.isoformat() if conversation.created_at else None,
"updated_at": conversation.updated_at.isoformat() if conversation.updated_at else None,
"messages": [
{
"id": msg.id,
"conversation_id": msg.conversation_id,
"role": msg.role,
"content": msg.content,
"created_at": msg.created_at.isoformat() if msg.created_at else None,
}
for msg in messages
],
}
@router.delete("/{conversation_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_conversation(
conversation_id: str,
db: Session = Depends(get_db),
current_user: Optional[User] = Depends(get_optional_user),
):
conversation = (
db.query(Conversation).filter(Conversation.id == conversation_id).first()
)
if not conversation:
raise HTTPException(status_code=404, detail="Conversation not found")
if conversation.user_id and current_user and conversation.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Access denied")
db.delete(conversation)
db.commit()
@router.post("/{conversation_id}/set-bot")
def set_bot_for_conversation(
conversation_id: str,
bot_id: str,
db: Session = Depends(get_db),
current_user: Optional[User] = Depends(get_optional_user),
request: Request = None,
):
conversation = (
db.query(Conversation).filter(Conversation.id == conversation_id).first()
)
if not conversation:
raise HTTPException(status_code=404, detail="Conversation not found")
if conversation.user_id and current_user and conversation.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Access denied")
if not current_user:
anonymous_token = request.cookies.get("anonymous_token") if request else None
if anonymous_token:
RateLimiter.check_anonymous_bot_limit(db, anonymous_token)
bot = db.query(Bot).filter(Bot.id == bot_id).first()
if not bot:
raise HTTPException(status_code=404, detail="Bot not found")
if current_user and bot.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not authorized to use this bot")
conversation.bot_id = bot_id
db.commit()
if not current_user and request:
anonymous_token = request.cookies.get("anonymous_token")
if anonymous_token:
RateLimiter.set_bot_created(db, anonymous_token)
return {"status": "updated", "bot_id": bot_id}
@router.post("/{conversation_id}/chat")
def chat_in_conversation(
conversation_id: str,
body: ChatRequest,
db: Session = Depends(get_db),
current_user: Optional[User] = Depends(get_optional_user),
request: Request = None,
response: Response = None,
):
conversation = (
db.query(Conversation).filter(Conversation.id == conversation_id).first()
)
if not conversation:
raise HTTPException(status_code=404, detail="Conversation not found")
if conversation.user_id and current_user and conversation.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Access denied")
warning = None
user_is_authenticated = current_user is not None
# Get anonymous_token from cookies or from the conversation itself
anonymous_token = None
if not current_user:
RateLimiter.check_system_limit(db)
# First try to get from conversation (more reliable)
anonymous_token = conversation.anonymous_token
# If not on conversation, try cookies
if not anonymous_token and request:
anonymous_token = request.cookies.get("anonymous_token")
# If still not found, create new one
if not anonymous_token:
anonymous_token = get_or_create_anonymous_token(request, response, db)
# Also set it on the conversation for future use
conversation.anonymous_token = anonymous_token
db.commit()
# Debug logging
import logging
logging.info(f"Anonymous chat: token={anonymous_token}, checking limit")
anon = RateLimiter.check_anonymous_limit(db, anonymous_token)
if anon:
logging.info(f"Anonymous user found: chat_count={anon.chat_count}")
else:
logging.info("Anonymous user NOT found in DB")
RateLimiter.increment_chat_count(db, anonymous_token)
if anon and anon.chat_count > 40:
warning = "Your progress is not saved."
# Always save the user's message first
user_msg = Message(
conversation_id=conversation_id,
role="user",
content=body.message,
)
db.add(user_msg)
# Get conversation history for context
conversation_history = (
db.query(Message)
.filter(Message.conversation_id == conversation_id)
.order_by(Message.created_at)
.all()
)
history_for_agent = [
{"role": msg.role, "content": msg.content} for msg in conversation_history[-10:]
]
# Get user_id
user_id = current_user.id if current_user else None
# Debug logging
print(f"DEBUG: conversation_id={conversation_id}")
print(f"DEBUG: conversation.bot_id={conversation.bot_id}")
print(f"DEBUG: conversation.anonymous_token={conversation.anonymous_token[:20] if conversation.anonymous_token else None}")
print(f"DEBUG: anonymous_token variable={anonymous_token[:20] if anonymous_token else None}")
# If no bot is set, use a general-purpose agent (without bot-specific context)
if not conversation.bot_id:
# Use the conversational agent with user context
agent = get_conversational_agent(
user_id=user_id,
conversation_id=conversation_id,
anonymous_token=anonymous_token,
)
result = agent.chat(body.message, history_for_agent)
assistant_content = result.get("response", "I couldn't process your request.")
# Refresh conversation to get updated bot_id (in case agent set it)
db.refresh(conversation)
# Save the assistant's response
assistant_msg = Message(
conversation_id=conversation_id,
role="assistant",
content=assistant_content,
)
db.add(assistant_msg)
db.commit()
# Fetch updated conversation with messages
conversation_history = (
db.query(Message)
.filter(Message.conversation_id == conversation_id)
.order_by(Message.created_at)
.all()
)
return {
"id": conversation.id,
"user_id": conversation.user_id,
"bot_id": conversation.bot_id,
"title": conversation.title,
"created_at": conversation.created_at.isoformat() if conversation.created_at else None,
"updated_at": conversation.updated_at.isoformat() if conversation.updated_at else None,
"messages": [
{
"id": msg.id,
"conversation_id": msg.conversation_id,
"role": msg.role,
"content": msg.content,
"created_at": msg.created_at.isoformat() if msg.created_at else None,
}
for msg in conversation_history
],
}
# Bot is set - process with the AI agent
agent = get_conversational_agent(
bot_id=conversation.bot_id,
user_id=user_id,
conversation_id=conversation_id,
anonymous_token=anonymous_token,
)
result = agent.chat(body.message, history_for_agent)
assistant_content = result.get("response", "I couldn't process your request.")
# Save the assistant's response
assistant_msg = Message(
conversation_id=conversation_id,
role="assistant",
content=assistant_content,
)
db.add(assistant_msg)
db.commit()
# Fetch updated conversation with messages
conversation_history = (
db.query(Message)
.filter(Message.conversation_id == conversation_id)
.order_by(Message.created_at)
.all()
)
return {
"id": conversation.id,
"user_id": conversation.user_id,
"bot_id": conversation.bot_id,
"title": conversation.title,
"created_at": conversation.created_at.isoformat() if conversation.created_at else None,
"updated_at": conversation.updated_at.isoformat() if conversation.updated_at else None,
"messages": [
{
"id": msg.id,
"conversation_id": msg.conversation_id,
"role": msg.role,
"content": msg.content,
"created_at": msg.created_at.isoformat() if msg.created_at else None,
}
for msg in conversation_history
],
}

View File

@@ -1,5 +1,6 @@
import uuid import uuid
import asyncio import asyncio
import logging
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -11,6 +12,9 @@ from ..core.database import get_db
from ..core.config import get_settings 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 ..db.models import Bot, Simulation, Signal, User
from ..services.ave.client import AveCloudClient
logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@@ -22,6 +26,7 @@ def run_simulation_sync(
simulation_id: str, db_url: str, bot_id: str, config: Dict[str, Any] simulation_id: str, db_url: str, bot_id: str, config: Dict[str, Any]
): ):
import asyncio import asyncio
import time
from ..services.simulate.engine import SimulateEngine from ..services.simulate.engine import SimulateEngine
from ..core.database import SessionLocal from ..core.database import SessionLocal
@@ -29,8 +34,19 @@ def run_simulation_sync(
engine = SimulateEngine(config) engine = SimulateEngine(config)
engine.run_id = simulation_id engine.run_id = simulation_id
running_simulations[simulation_id] = engine running_simulations[simulation_id] = engine
try:
results = await engine.run() # 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() db = SessionLocal()
try: try:
simulation = ( simulation = (
@@ -38,27 +54,50 @@ def run_simulation_sync(
) )
if simulation: if simulation:
simulation.status = engine.status simulation.status = engine.status
simulation.signals = engine.signals simulation.signals = [serialize_signal(s) for s in engine.signals]
db.commit() simulation.klines = [
{"time": k.get("time"), "close": k.get("close")}
for signal in engine.signals: for k in engine.klines
db_signal = Signal( ]
id=signal["id"], simulation.trade_log = engine.trade_log
bot_id=signal["bot_id"], # Save portfolio data
run_id=signal["run_id"], simulation.portfolio = {
signal_type=signal["signal_type"], "initial_balance": engine.config.get("initial_balance", 10000),
token=signal["token"], "current_balance": engine.current_balance,
price=signal["price"], "position": engine.position,
confidence=signal.get("confidence"), "position_token": engine.position_token,
reasoning=signal.get("reasoning"), "entry_price": engine.entry_price,
executed=signal.get("executed", False), "current_price": engine.last_close,
created_at=signal["created_at"], }
)
db.add(db_signal)
db.commit() db.commit()
finally: finally:
db.close() 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: finally:
# Save final state
save_progress()
if simulation_id in running_simulations: if simulation_id in running_simulations:
del running_simulations[simulation_id] del running_simulations[simulation_id]
@@ -87,20 +126,35 @@ async def start_simulation(
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized" 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() settings = get_settings()
simulation_id = str(uuid.uuid4()) simulation_id = str(uuid.uuid4())
check_interval = config.check_interval # Create AVE client for klines fetching
if settings.AVE_API_PLAN != "pro" and check_interval < 60: ave_client = AveCloudClient(
check_interval = 60 api_key=settings.AVE_API_KEY,
plan=settings.AVE_API_PLAN,
)
simulation_config = { simulation_config = {
"bot_id": bot_id, "bot_id": bot_id,
"token": config.token, "token": config.token,
"chain": config.chain, "chain": config.chain,
"duration_seconds": config.duration_seconds, "kline_interval": config.kline_interval,
"check_interval": check_interval, "auto_execute": False, # Always paper trade
"auto_execute": config.auto_execute,
"strategy_config": bot.strategy_config, "strategy_config": bot.strategy_config,
"ave_api_key": settings.AVE_API_KEY, "ave_api_key": settings.AVE_API_KEY,
"ave_api_plan": settings.AVE_API_PLAN, "ave_api_plan": settings.AVE_API_PLAN,
@@ -114,19 +168,46 @@ async def start_simulation(
config={ config={
"token": config.token, "token": config.token,
"chain": config.chain, "chain": config.chain,
"duration_seconds": config.duration_seconds, "kline_interval": config.kline_interval,
"check_interval": check_interval,
"auto_execute": config.auto_execute,
}, },
signals=[], signals=[],
klines=[],
) )
db.add(simulation) db.add(simulation)
db.commit() db.commit()
db.refresh(simulation) db.refresh(simulation)
db_url = str(settings.DATABASE_URL) # 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( background_tasks.add_task(
run_simulation_sync, simulation_id, db_url, bot_id, simulation_config run_simulation_sync, simulation_id, str(settings.DATABASE_URL), bot_id, simulation_config
) )
return simulation return simulation
@@ -193,6 +274,9 @@ def list_simulations(
if sim.id in running_simulations: if sim.id in running_simulations:
engine = running_simulations[sim.id] engine = running_simulations[sim.id]
sim.signals = engine.get_signals() 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 return simulations
@@ -224,10 +308,15 @@ def stop_simulation(
status_code=status.HTTP_404_NOT_FOUND, detail="Simulation not found" 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: if run_id in running_simulations:
engine = running_simulations[run_id] engine = running_simulations[run_id]
asyncio.create_task(engine.stop()) engine.stop()
simulation.status = "stopped" del running_simulations[run_id]
db.commit() db.commit()
return {"status": "stopping", "run_id": run_id} 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

@@ -10,6 +10,7 @@ from sqlalchemy import (
ForeignKey, ForeignKey,
Index, Index,
JSON, JSON,
Integer,
) )
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from ..core.database import Base from ..core.database import Base
@@ -30,13 +31,16 @@ class User(Base):
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
bots = relationship("Bot", back_populates="user", cascade="all, delete-orphan") bots = relationship("Bot", back_populates="user", cascade="all, delete-orphan")
conversations = relationship(
"Conversation", back_populates="user", cascade="all, delete-orphan"
)
class Bot(Base): class Bot(Base):
__tablename__ = "bots" __tablename__ = "bots"
id = Column(String, primary_key=True, default=generate_uuid) id = Column(String, primary_key=True, default=generate_uuid)
user_id = Column(String, ForeignKey("users.id"), nullable=False) user_id = Column(String, ForeignKey("users.id"), nullable=True) # nullable for anonymous bots
name = Column(String, nullable=False) name = Column(String, nullable=False)
description = Column(Text) description = Column(Text)
strategy_config = Column(JSON, nullable=False) strategy_config = Column(JSON, nullable=False)
@@ -47,6 +51,9 @@ class Bot(Base):
user = relationship("User", back_populates="bots") user = relationship("User", back_populates="bots")
conversations = relationship( conversations = relationship(
"Conversation", back_populates="bot", cascade="all, delete-orphan"
)
bot_conversations = relationship(
"BotConversation", back_populates="bot", cascade="all, delete-orphan" "BotConversation", back_populates="bot", cascade="all, delete-orphan"
) )
backtests = relationship( backtests = relationship(
@@ -58,6 +65,47 @@ class Bot(Base):
signals = relationship("Signal", back_populates="bot", cascade="all, delete-orphan") signals = relationship("Signal", back_populates="bot", cascade="all, delete-orphan")
class Conversation(Base):
__tablename__ = "conversations"
id = Column(String, primary_key=True, default=generate_uuid)
user_id = Column(String, ForeignKey("users.id"), nullable=True)
anonymous_token = Column(String(64), nullable=True)
bot_id = Column(String, ForeignKey("bots.id"), nullable=True)
title = Column(String(255), default="New Conversation")
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
user = relationship("User", back_populates="conversations")
bot = relationship("Bot", back_populates="conversations")
messages = relationship(
"Message", back_populates="conversation", cascade="all, delete-orphan"
)
class Message(Base):
__tablename__ = "messages"
id = Column(String, primary_key=True, default=generate_uuid)
conversation_id = Column(String, ForeignKey("conversations.id"), nullable=True)
role = Column(String, nullable=False)
content = Column(Text, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
conversation = relationship("Conversation", back_populates="messages")
class AnonymousUser(Base):
__tablename__ = "anonymous_users"
id = Column(String(64), primary_key=True)
chat_count = Column(Integer, default=0)
bot_created = Column(Boolean, default=False)
backtest_count = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class BotConversation(Base): class BotConversation(Base):
__tablename__ = "bot_conversations" __tablename__ = "bot_conversations"
@@ -67,7 +115,7 @@ class BotConversation(Base):
content = Column(Text, nullable=False) content = Column(Text, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
bot = relationship("Bot", back_populates="conversations") bot = relationship("Bot", back_populates="bot_conversations")
class Backtest(Base): class Backtest(Base):
@@ -93,6 +141,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")
@@ -115,7 +166,10 @@ class Signal(Base):
Index("idx_bots_user_id", Bot.user_id) Index("idx_bots_user_id", Bot.user_id)
Index("idx_conversations_bot_id", BotConversation.bot_id) Index("idx_conversations_user_id", Conversation.user_id)
Index("idx_conversations_bot_id", Conversation.bot_id)
Index("idx_messages_conversation_id", Message.conversation_id)
Index("idx_bot_conversations_bot_id", BotConversation.bot_id)
Index("idx_backtests_bot_id", Backtest.bot_id) Index("idx_backtests_bot_id", Backtest.bot_id)
Index("idx_simulations_bot_id", Simulation.bot_id) Index("idx_simulations_bot_id", Simulation.bot_id)
Index("idx_signals_bot_id", Signal.bot_id) Index("idx_signals_bot_id", Signal.bot_id)

View File

@@ -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
@@ -49,7 +54,7 @@ class BotUpdate(BaseModel):
class BotResponse(BaseModel): class BotResponse(BaseModel):
id: str id: str
user_id: str user_id: Optional[str] # None for anonymous bots
name: str name: str
description: Optional[str] description: Optional[str]
strategy_config: dict strategy_config: dict
@@ -64,6 +69,7 @@ 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
@@ -85,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
@@ -93,9 +100,7 @@ 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"
check_interval: int = 60
auto_execute: bool = False
@field_validator("chain") @field_validator("chain")
@classmethod @classmethod
@@ -112,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
@@ -140,8 +151,12 @@ class BotChatRequest(BaseModel):
class BotChatResponse(BaseModel): class BotChatResponse(BaseModel):
response: str response: str
thinking: Optional[str] = None
strategy_config: Optional[dict] = None strategy_config: Optional[dict] = None
success: bool = False 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):
@@ -227,3 +242,57 @@ class AveChainSwapRequest(BaseModel):
class AveChainSwapResponse(BaseModel): class AveChainSwapResponse(BaseModel):
swap: Optional[dict] = None swap: Optional[dict] = None
upsell_message: Optional[str] = None upsell_message: Optional[str] = None
class ConversationResponse(BaseModel):
id: str
user_id: Optional[str]
anonymous_token: Optional[str]
bot_id: Optional[str]
title: str
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class MessageResponse(BaseModel):
id: str
conversation_id: Optional[str]
role: str
content: str
created_at: datetime
class Config:
from_attributes = True
class ConversationWithMessagesResponse(BaseModel):
id: str
user_id: Optional[str]
anonymous_token: Optional[str]
bot_id: Optional[str]
title: str
created_at: datetime
updated_at: datetime
messages: List[MessageResponse] = []
class Config:
from_attributes = True
class SetBotRequest(BaseModel):
bot_id: str
class ChatRequest(BaseModel):
message: str
class ChatResponse(BaseModel):
response: str
thinking: Optional[str] = None
strategy_config: Optional[dict] = None
success: bool = False
warning: Optional[str] = None

View File

@@ -4,7 +4,7 @@ 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, ave from .api import auth, bots, backtest, simulate, config, ave, conversations
from .core.limiter import limiter from .core.limiter import limiter
from .core.database import engine, Base from .core.database import engine, Base
@@ -15,7 +15,17 @@ logger = logging.getLogger(__name__)
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Initialize database on startup.""" """Initialize database on startup."""
# Import all models to ensure they're registered # Import all models to ensure they're registered
from .db.models import User, Bot, BotConversation, Backtest, Simulation, Signal from .db.models import (
User,
Bot,
BotConversation,
Backtest,
Simulation,
Signal,
Conversation,
Message,
AnonymousUser,
)
# Create tables if they don't exist # Create tables if they don't exist
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
@@ -44,6 +54,7 @@ app.add_middleware(
app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(bots.router, prefix="/api/bots", tags=["bots"]) app.include_router(bots.router, prefix="/api/bots", tags=["bots"])
app.include_router(conversations.router, tags=["conversations"])
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"])

View File

@@ -1,4 +1,29 @@
"""AI Agent module for conversational trading."""
from .agent import ConversationalAgent, get_conversational_agent
from .client import MiniMaxClient
from .tools import get_tool_registry, TOOL_REGISTRY
from .help import (
format_tools_list,
format_general_help,
format_tool_help,
format_skill_acknowledgment,
)
from .crew import TradingCrew, get_trading_crew from .crew import TradingCrew, get_trading_crew
from .llm_connector import MiniMaxLLM, MiniMaxConnector from .llm_connector import MiniMaxLLM, MiniMaxConnector
__all__ = ["TradingCrew", "get_trading_crew", "MiniMaxLLM", "MiniMaxConnector"] __all__ = [
"ConversationalAgent",
"get_conversational_agent",
"MiniMaxClient",
"get_tool_registry",
"TOOL_REGISTRY",
"format_tools_list",
"format_general_help",
"format_tool_help",
"format_skill_acknowledgment",
"TradingCrew",
"get_trading_crew",
"MiniMaxLLM",
"MiniMaxConnector",
]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,451 @@
"""MiniMax API client for the conversational agent."""
import requests
from typing import Dict, Any, Optional, List
SYSTEM_PROMPT = """You are a helpful AI trading assistant named Randebu. You help users manage their trading bots.
IMPORTANT CHAIN LIMITATION:
- We ONLY support BSC (Binance Smart Chain) blockchain
- If user asks about any other chain (Solana, ETH, Base, etc.), respond with: "Currently we only support BSC (Binance Smart Chain). All trading strategies and token searches are performed on BSC."
- Never search or recommend tokens on other chains
- The search_tokens tool defaults to BSC, never change this
Your response must be valid JSON with exactly this structure:
{
"thinking": "Your internal reasoning and analysis (what you're thinking about)",
"response": "Your actual response to the user (be concise and helpful)",
"strategy_update": null or {
"conditions": [{"type": "price_drop" | "price_rise" | "volume_spike" | "price_level", "token": "TOKEN_SYMBOL", "token_address": null, "threshold": number, ...}],
"actions": [{"type": "buy" | "sell" | "hold", "amount_percent": number, ...}],
"risk_management": {"stop_loss_percent": number, "take_profit_percent": number}
}
}
Guidelines:
- "thinking" should be detailed reasoning about the user's request
- "response" should be conversational and clear
- "strategy_update" should be populated ONLY when the user provides specific trading parameters (percentages, tokens, conditions, etc.)
- IMPORTANT: When a token is mentioned, set "token_address": null and ask user to confirm the token address before saving. Your response should say something like: "I need to confirm the token address. Could you provide the contract address for [TOKEN]?"
- If no strategy parameters are provided, set "strategy_update" to null
- Be friendly, concise, and helpful in your response
Example 1 (no strategy update):
User: "What can this bot do?"
{
"thinking": "The user is asking about the bot's capabilities. I should explain the main features.",
"response": "Randebu is your AI trading assistant! It can monitor cryptocurrency prices and execute trades based on your configured strategies. Tell me your trading parameters and I'll set them up for you.",
"strategy_update": null
}
Example 2 (token needs confirmation):
User: "I want to buy PEPE when it drops 10%"
{
"thinking": "User wants to buy PEPE. I need the token contract address to proceed. I should ask for confirmation.",
"response": "I'd be happy to set up a buy order for PEPE! However, I need to confirm the token contract address. Could you provide the BSC contract address for PEPE? (It usually starts with 0x...)",
"strategy_update": {
"conditions": [{"type": "price_drop", "token": "PEPE", "token_address": null, "threshold": 10}],
"actions": [{"type": "buy", "amount_percent": 100}],
"risk_management": null
}
}
Example 3 (with token address provided by user):
User: "Buy 0x6982508145454Ce125dDE157d8d64a26D53f60a2 when it drops 10%"
{
"thinking": "User provided a contract address, I can use it directly.",
"response": "Perfect! I've configured your strategy to buy the token when it drops 10%.",
"strategy_update": {
"conditions": [{"type": "price_drop", "token": "TOKEN", "token_address": "0x6982508145454Ce125dDE157d8d64a26D53f60a2", "threshold": 10}],
"actions": [{"type": "buy", "amount_percent": 100}],
"risk_management": null
}
}"""
TOOLS = [
{
"type": "function",
"function": {
"name": "search_tokens",
"description": "Search for tokens by keyword on BSC blockchain. Use this when user asks to search for a specific token or find tokens by name/symbol.",
"parameters": {
"type": "object",
"properties": {
"keyword": {
"type": "string",
"description": "Token symbol or name to search for (e.g., 'PEPE', 'BTC')",
},
"limit": {
"type": "integer",
"description": "Number of tokens to return (default: 10)",
"default": 10,
},
},
"required": ["keyword"],
},
},
},
{
"type": "function",
"function": {
"name": "get_token",
"description": "Get detailed information about a specific token including price, market cap, and pairs. Use when user asks for token details or wants to find a specific token by contract address.",
"parameters": {
"type": "object",
"properties": {
"address": {
"type": "string",
"description": "Token contract address (e.g., '0x6982508145454Ce125dDE157d8d64a26D53f60a2')",
},
"chain": {
"type": "string",
"description": "Blockchain chain (default: bsc)",
"default": "bsc",
},
},
"required": ["address"],
},
},
},
{
"type": "function",
"function": {
"name": "get_price",
"description": "Get current price(s) for tokens. Use when user asks for token price or wants to compare prices of multiple tokens.",
"parameters": {
"type": "object",
"properties": {
"token_ids": {
"type": "string",
"description": "Comma-separated list of token IDs with chain suffix (e.g., 'PEPE-bsc,TRUMP-bsc')",
}
},
"required": ["token_ids"],
},
},
},
{
"type": "function",
"function": {
"name": "get_risk",
"description": "Get risk analysis for a token contract. Use when user asks about token risk, honeypot analysis, or safety assessment before trading.",
"parameters": {
"type": "object",
"properties": {
"address": {
"type": "string",
"description": "Token contract address (e.g., '0x6982508145454Ce125dDE157d8d64a26D53f60a2')",
},
"chain": {
"type": "string",
"description": "Blockchain chain (default: bsc)",
"default": "bsc",
},
},
"required": ["address"],
},
},
},
{
"type": "function",
"function": {
"name": "get_trending",
"description": "Get trending tokens on a blockchain. Use when user asks what's trending, top tokens, or popular tokens right now.",
"parameters": {
"type": "object",
"properties": {
"chain": {
"type": "string",
"description": "Blockchain chain (default: bsc)",
"default": "bsc",
},
"limit": {
"type": "integer",
"description": "Number of trending tokens to return (default: 10, max: 50)",
"default": 10,
},
},
},
},
},
{
"type": "function",
"function": {
"name": "run_backtest",
"description": "Run a backtest to evaluate how the current trading strategy would have performed historically. Returns key metrics like ROI, win rate, max drawdown, etc. Use this when user asks to backtest, test strategy, or check historical performance.",
"parameters": {
"type": "object",
"properties": {
"token_address": {
"type": "string",
"description": "The BSC contract address of the token to backtest (required)",
},
"timeframe": {
"type": "string",
"description": "Timeframe for klines: '1d' (1 day), '4h' (4 hours), '1h' (1 hour), '15m' (15 minutes)",
"default": "1d",
},
"start_date": {
"type": "string",
"description": "Start date for backtest in YYYY-MM-DD format (e.g., '2024-01-01')",
},
"end_date": {
"type": "string",
"description": "End date for backtest in YYYY-MM-DD format (e.g., '2024-12-01')",
},
},
"required": ["token_address"],
},
},
},
{
"type": "function",
"function": {
"name": "manage_simulation",
"description": "Manage trading simulations: start, stop, or check status. Simulations run on real-time klines and show live portfolio updates. Use when user asks to run simulation, check simulation status, or stop simulation.",
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["start", "stop", "status", "results"],
"description": "Action to perform: 'start' (begin new simulation), 'stop' (stop running simulation), 'status' (check if simulation is running), 'results' (get results from current or latest simulation)",
},
"token_address": {
"type": "string",
"description": "Token contract address for simulation (required for 'start' action)",
},
"kline_interval": {
"type": "string",
"description": "Kline interval: '1m', '5m', '15m', '1h' (default: '1m')",
"default": "1m",
},
},
"required": ["action"],
},
},
},
{
"type": "function",
"function": {
"name": "create_bot",
"description": "Create a new trading bot. Use this when user wants to create a new trading bot. Returns the bot ID and confirmation.",
"parameters": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Name for the new bot (required)",
},
"strategy": {
"type": "string",
"description": "Trading strategy description in plain English (optional, can be set later)",
},
},
"required": ["name"],
},
},
},
{
"type": "function",
"function": {
"name": "list_bots",
"description": "List all trading bots owned by the user. Use this when user wants to see their bots or asks which bots they have.",
"parameters": {
"type": "object",
"properties": {},
},
},
},
{
"type": "function",
"function": {
"name": "set_bot",
"description": "Set (associate) a bot with the current conversation. Use this when user wants to switch which bot they're working with.",
"parameters": {
"type": "object",
"properties": {
"bot_id": {
"type": "string",
"description": "ID of the bot to set for this conversation (required)",
},
},
"required": ["bot_id"],
},
},
},
{
"type": "function",
"function": {
"name": "get_bot_info",
"description": "Get details of a specific bot including name, strategy, and status. Use this when user wants to see details of a bot.",
"parameters": {
"type": "object",
"properties": {
"bot_id": {
"type": "string",
"description": "ID of the bot to get info for (optional, defaults to current bot)",
},
},
},
},
},
{
"type": "function",
"function": {
"name": "update_strategy",
"description": "Update (save) the trading strategy for a bot. This SAVES the strategy to the database. ALWAYS call this tool when user wants to configure or update a trading strategy. The strategy will be persisted and used for backtests.",
"parameters": {
"type": "object",
"properties": {
"strategy": {
"type": "string",
"description": "Description of the strategy (e.g., 'Dip buying strategy', 'Mean reversion')",
},
"conditions": {
"type": "array",
"description": "Array of condition objects. Each condition has: type (price_drop, price_rise, volume_spike, price_level), token, token_address, threshold",
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["price_drop", "price_rise", "volume_spike", "price_level"],
"description": "Type of condition"
},
"token": {"type": "string", "description": "Token symbol (e.g., 'SHIB', 'PEPE')"},
"token_address": {"type": "string", "description": "Token contract address on BSC (e.g., '0x...') - REQUIRED"},
"threshold": {"type": "number", "description": "Threshold value (e.g., 5 for 5% drop/rise)"},
},
"required": ["type", "token_address", "threshold"]
}
},
"actions": {
"type": "array",
"description": "Array of action objects. Each action has: type (buy, sell, hold), amount_percent",
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["buy", "sell", "hold"],
"description": "Type of action"
},
"amount_percent": {"type": "number", "description": "Percentage of funds to use (e.g., 20 for 20%)"},
},
"required": ["type", "amount_percent"]
}
},
"stop_loss": {"type": "number", "description": "Stop loss percentage (e.g., 15 for 15% loss)"},
"take_profit": {"type": "number", "description": "Take profit percentage (e.g., 20 for 20% gain)"},
},
"required": ["conditions", "actions"]
},
},
},
]
SYSTEM_PROMPT_WITH_TOOLS = (
SYSTEM_PROMPT
+ """
CRITICAL INSTRUCTIONS:
1. When user asks to run a backtest or simulation, first check if a bot exists using list_bots.
2. If no bot exists, ASK THE USER what they want to name their bot (e.g., "What would you like to name your trading bot?").
3. When user provides a bot name (after being asked), call create_bot with that name.
4. If a bot exists but has NO STRATEGY SET, the user MUST set up a strategy before running backtests. Ask the user for their trading strategy (e.g., "What token should I monitor?", "What price drop percentage should trigger a buy?", "What percentage of funds should be used?"). Help them configure the strategy.
5. IMPORTANT: When configuring a strategy, you MUST call the update_strategy tool with COMPLETE details:
- conditions: Array of {type: "price_drop"|"price_rise"|"volume_spike", token: "SYMBOL", token_address: "0x...", threshold: number}
- actions: Array of {type: "buy"|"sell", amount_percent: number}
- stop_loss: number (e.g., 5 for 5%)
- take_profit: number (e.g., 15 for 15%)
- ALWAYS include the token_address from search results when configuring strategy!
6. IMPORTANT: After executing ANY tool, you MUST incorporate the ACTUAL tool result into your response. Do NOT ignore what the tool returned. If the tool returned an error, you MUST tell the user about the error - do NOT pretend the operation succeeded.
7. NEVER tell users about internal tool names like "create_bot", "list_bots", etc. Use natural language instead.
8. NEVER say "Let me..." or "I'll..." - IMMEDIATELY call the appropriate tool. If user asks for trending tokens, call get_trending NOW. If user asks for token info, call get_token NOW. Do NOT ask for permission or say you will do something - just do it.
9. When asking for information from the user, be specific and actionable (e.g., "What token do you want to backtest?", "What would you like to name your bot?").
10. If user asks you to look up/search/find tokens, IMMEDIATELY call search_tokens or get_trending tool. Do not ask follow-up questions first.
11. IMPORTANT: When user says they want to use a token from search results (e.g., "that main PEPE" or "the first one"), ALWAYS extract the token_address from the search results and include it in update_strategy. Do NOT ask for the address again!
You have access to tools:
- search_tokens(keyword, limit): Search for tokens by keyword/symbol.
- get_token(address, chain): Get detailed token info.
- get_price(token_ids): Get current token prices.
- get_risk(address, chain): Get risk/honeypot analysis.
- get_trending(chain, limit): Get trending tokens.
- run_backtest(token_address, timeframe, start_date, end_date): Run backtest. REQUIRES a bot to be set first.
- manage_simulation(action, token_address, kline_interval): Manage simulations. REQUIRES a bot.
- create_bot(name, strategy): Create a new trading bot.
- list_bots(): List user's bots.
- set_bot(bot_id): Switch bots in conversation.
- get_bot_info(bot_id): Get bot details including strategy conditions and actions.
- update_strategy(conditions, actions, stop_loss, take_profit): Save trading strategy to bot. MUST include token_address in conditions!
Take action immediately. Do not ask for confirmation. Do not describe what you will do - just do it.
"""
)
class MiniMaxClient:
"""Client for MiniMax extended thinking API."""
def __init__(self, api_key: str, model: str = "MiniMax-M2.7"):
self.api_key = api_key
self.model = model
self.endpoint = "https://api.minimax.io/v1/text/chatcompletion_v2"
def chat(
self,
messages: List[Dict[str, str]],
system_prompt: str,
tools: Optional[List[Dict[str, Any]]] = None,
temperature: float = 0.7,
max_tokens: int = 3000,
thinking_budget: int = 1500,
) -> Dict[str, Any]:
"""Send a chat request to MiniMax API."""
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
all_messages = [{"role": "system", "content": system_prompt}] + messages
payload = {
"model": self.model,
"messages": all_messages,
"temperature": temperature,
"max_tokens": max_tokens,
"thinking": {"type": "human", "budget_tokens": thinking_budget},
}
if tools:
payload["tools"] = tools
resp = requests.post(self.endpoint, headers=headers, json=payload)
# Check for HTTP errors
if resp.status_code != 200:
error_text = resp.text
print(f"API Error {resp.status_code}: {error_text[:500]}")
return {"error": f"API returned {resp.status_code}: {error_text[:200]}"}
return resp.json() or {}
def check_connection(self) -> bool:
"""Check if API is reachable."""
try:
resp = requests.post(
self.endpoint,
headers={"Authorization": f"Bearer {self.api_key}"},
json={
"model": self.model,
"messages": [{"role": "user", "content": "ping"}],
},
timeout=10,
)
return resp.status_code == 200
except Exception:
return False

View File

@@ -1,6 +1,6 @@
from typing import List, Optional, Dict, Any from typing import List, Optional, Dict, Any
from crewai import Agent, Task, Crew from crewai import Agent, Task, Crew, LLM
from .llm_connector import MiniMaxConnector, MiniMaxLLM from .llm_connector import MiniMaxConnector
from ...core.config import get_settings from ...core.config import get_settings
@@ -120,7 +120,7 @@ class StrategyExplainer:
def create_trading_designer_agent( def create_trading_designer_agent(
api_key: str, model: str = "MiniMax-Text-01" api_key: str, model: str = "MiniMax-M2.7"
) -> Agent: ) -> Agent:
connector = MiniMaxConnector(api_key=api_key, model=model) connector = MiniMaxConnector(api_key=api_key, model=model)
@@ -141,13 +141,13 @@ def create_trading_designer_agent(
role="Trading Strategy Designer", role="Trading Strategy Designer",
goal="Convert natural language trading requests into precise strategy configurations", goal="Convert natural language trading requests into precise strategy configurations",
backstory=system_prompt, backstory=system_prompt,
llm=MiniMaxLLM(api_key=api_key, model=model), llm=LLM(model=model, api_key=api_key, api_base="https://api.minimax.io/v1"),
verbose=True, verbose=True,
) )
def create_strategy_validator_agent( def create_strategy_validator_agent(
api_key: str, model: str = "MiniMax-Text-01" api_key: str, model: str = "MiniMax-M2.7"
) -> Agent: ) -> Agent:
return Agent( return Agent(
role="Strategy Validator", role="Strategy Validator",
@@ -155,13 +155,13 @@ def create_strategy_validator_agent(
backstory="""You are a meticulous strategy validator with expertise in trading systems. 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 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.""", strategy makes logical sense. You never approve strategies with missing or invalid data.""",
llm=MiniMaxLLM(api_key=api_key, model=model), llm=LLM(model=model, api_key=api_key, api_base="https://api.minimax.io/v1"),
verbose=True, verbose=True,
) )
def create_strategy_explainer_agent( def create_strategy_explainer_agent(
api_key: str, model: str = "MiniMax-Text-01" api_key: str, model: str = "MiniMax-M2.7"
) -> Agent: ) -> Agent:
return Agent( return Agent(
role="Strategy Explainer", role="Strategy Explainer",
@@ -169,13 +169,13 @@ def create_strategy_explainer_agent(
backstory="""You are a patient trading strategy explainer. You translate complex backstory="""You are a patient trading strategy explainer. You translate complex
strategy configurations into easy-to-understand language. You help users understand strategy configurations into easy-to-understand language. You help users understand
exactly what their strategies will do when triggered.""", exactly what their strategies will do when triggered.""",
llm=MiniMaxLLM(api_key=api_key, model=model), llm=LLM(model=model, api_key=api_key, api_base="https://api.minimax.io/v1"),
verbose=True, verbose=True,
) )
class TradingCrew: class TradingCrew:
def __init__(self, api_key: str, model: str = "MiniMax-Text-01"): 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
self.validator = StrategyValidator() self.validator = StrategyValidator()

View File

@@ -0,0 +1,83 @@
"""Help formatters for slash commands and tool documentation."""
from typing import Optional
from .tools import get_tool_registry, SKILL_EMOJIS
def format_tools_list() -> str:
"""Format the tool registry as a help message."""
message = "📋 Available Tools\n\n"
for category in ["randebu", "ave"]:
tools = get_tool_registry().get(category, [])
if category == "randebu":
message += "🤖 Randebu Built-in:\n"
else:
message += "☁️ AVE Cloud Skills:\n"
for tool in tools:
message += f"{tool['command']} - {tool['description']}\n"
message += "\n"
message = (
message.rstrip() + "\n\nType /<tool-name> for detailed help on a specific tool."
)
return message
def format_skill_acknowledgment(tool_name: str, description: str) -> str:
"""Format a brief acknowledgment when a skill is activated."""
emoji = SKILL_EMOJIS.get(tool_name.lower(), "")
return f"{emoji} **{tool_name}** loaded. Ready for *{description}*, ask me away!"
def format_tool_help(tool_name: str) -> str:
"""Format detailed help for a specific tool."""
tool_name = tool_name.lstrip("/")
for category in ["randebu", "ave"]:
for tool in get_tool_registry().get(category, []):
if tool["name"].lower() == tool_name.lower():
cat_label = (
"Randebu Built-in" if category == "randebu" else "AVE Cloud Skill"
)
details = tool["details"]
message = (
f"🔍 {tool['command']} - {details['description']} ({cat_label})\n\n"
)
message += f"**Description:** {details['description']}\n"
message += f"**Commands:**\n {details['usage']}\n\n"
message += f"**Example:**\n```\n{details['example']}\n```"
return message
return f"Tool '{tool_name}' not found. Type / to see all available tools."
def format_general_help() -> str:
"""Format general help about Randebu."""
return """🤖 **Randebu - AI Trading Assistant**
Randebu is your AI trading assistant that helps you manage your trading bots on BSC (Binance Smart Chain).
**Getting Started:**
1. Create a bot on the dashboard
2. Describe your trading strategy in plain English
3. Run backtests to validate your strategy
4. Start simulations to see live trading
**Example Strategies:**
- "Buy PEPE when it drops 5%"
- "Sell if price rises 10% within 1 hour"
- "Buy when volume spikes by 200%"
**Slash Commands:**
- `/` - Show all available tools
- `/help` - Show this help message
- `/<tool-name>` - Get help on a specific tool
**Natural Language:**
You can also just describe what you want in natural language. For example:
- "What's the price of PEPE?"
- "Run a backtest on 0x... token"
- "Start a simulation on TRUMP"
"""

View File

@@ -1,14 +1,12 @@
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
import httpx import httpx
from crewai import LLM
class MiniMaxLLM(LLM): class MiniMaxLLM:
def __init__(self, api_key: str, model: str = "MiniMax-Text-01", **kwargs): def __init__(self, api_key: str, model: str = "MiniMax-M2.7", **kwargs):
super().__init__(**kwargs)
self.api_key = api_key self.api_key = api_key
self.model = model self.model = model
self.base_url = "https://api.minimax.chat/v1" self.base_url = "https://api.minimax.io/v1"
def _call(self, messages: List[Dict[str, str]], **kwargs) -> str: def _call(self, messages: List[Dict[str, str]], **kwargs) -> str:
headers = { headers = {
@@ -23,7 +21,7 @@ class MiniMaxLLM(LLM):
} }
with httpx.Client(timeout=60.0) as client: with httpx.Client(timeout=60.0) as client:
response = client.post( response = client.post(
f"{self.base_url}/chat/completions", f"{self.base_url}/text/chatcompletion_v2",
headers=headers, headers=headers,
json=payload, json=payload,
) )
@@ -35,7 +33,7 @@ class MiniMaxLLM(LLM):
class MiniMaxConnector: class MiniMaxConnector:
def __init__(self, api_key: str, model: str = "MiniMax-Text-01"): 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

View File

@@ -0,0 +1,164 @@
"""Mock client for testing the ConversationalAgent without hitting real APIs."""
from typing import List, Dict, Any, Optional
class MockMiniMaxClient:
"""Mock client that returns predefined responses for testing.
Usage:
mock = MockMiniMaxClient()
mock.add_response({\"choices\": [...]}) # Add responses in order
mock.add_response({\"choices\": [...]}) # Second call gets this
agent = ConversationalAgent(client=mock)
result = agent.chat(\"hello\")
"""
def __init__(self, responses: List[Dict[str, Any]] = None):
self.responses = responses or []
self.call_count = 0
self.calls: List[Dict[str, Any]] = [] # Record all calls for assertions
def add_response(self, response: Dict[str, Any]):
"""Add a response to be returned on the next call."""
self.responses.append(response)
def chat(
self,
messages: List[Dict[str, str]],
system_prompt: str,
tools: Optional[List[Dict[str, Any]]] = None,
temperature: float = 0.7,
max_tokens: int = 2000,
thinking_budget: int = 1500,
) -> Dict[str, Any]:
"""Return the next predefined response."""
# Record the call for debugging/tests
self.calls.append({
"messages": messages,
"system_prompt": system_prompt[:100] if system_prompt else None,
"tool_calls_count": len(tools) if tools else 0,
})
if self.call_count < len(self.responses):
response = self.responses[self.call_count]
self.call_count += 1
return response
# Default response if no more predefined responses
return {
"choices": [{
"message": {
"content": "Mock response - no more responses configured",
"role": "assistant",
}
}]
}
def reset(self):
"""Reset call count and calls list."""
self.call_count = 0
self.calls = []
def verify_call(self, call_index: int, expected_messages: int = None, expected_tool_count: int = None):
"""Verify a specific call was made correctly."""
if call_index >= len(self.calls):
raise AssertionError(f"Call {call_index} was not made. Total calls: {len(self.calls)}")
call = self.calls[call_index]
if expected_messages is not None:
actual = len(call["messages"])
if actual != expected_messages:
raise AssertionError(
f"Call {call_index}: expected {expected_messages} messages, got {actual}"
)
if expected_tool_count is not None:
actual = call["tool_calls_count"]
if actual != expected_tool_count:
raise AssertionError(
f"Call {call_index}: expected {expected_tool_count} tools, got {actual}"
)
class MockMiniMaxClientWithToolCall(MockMiniMaxClient):
"""Mock client that generates tool call responses based on message content."""
def __init__(self, tool_handlers: Dict[str, Dict[str, Any]] = None):
"""tool_handlers: dict mapping tool names to their responses."""
super().__init__()
self.tool_handlers = tool_handlers or {}
def chat(
self,
messages: List[Dict[str, str]],
system_prompt: str,
tools: Optional[List[Dict[str, Any]]] = None,
temperature: float = 0.7,
max_tokens: int = 2000,
thinking_budget: int = 1500,
) -> Dict[str, Any]:
"""Check if last message contains a tool call request and return appropriate response."""
self.calls.append({
"messages": messages,
"system_prompt": system_prompt[:100] if system_prompt else None,
"tool_calls_count": len(tools) if tools else 0,
})
# Get the last message
if not messages:
return {"choices": [{"message": {"content": "No messages", "role": "assistant"}}]}
last_msg = messages[-1]
# If it's a tool result, look for the tool that was called
if last_msg.get("role") == "user" and "[TOOL RESULT]" in last_msg.get("content", ""):
# Extract tool name from the content
content = last_msg["content"]
# Format: [TOOL RESULT] tool_name: result
for tool_name in self.tool_handlers:
if f"[TOOL RESULT] {tool_name}:" in content:
return self.tool_handlers[tool_name]
# Check if we should generate a tool call
last_user_msg = ""
for msg in reversed(messages):
if msg.get("role") == "user" and "[TOOL RESULT]" not in msg.get("content", ""):
last_user_msg = msg.get("content", "")
break
# Check each tool's trigger
for tool_name, handler in self.tool_handlers.items():
trigger = handler.get("trigger", "")
if trigger and trigger.lower() in last_user_msg.lower():
return {
"choices": [{
"message": {
"content": handler.get("content", ""),
"role": "assistant",
"tool_calls": [{
"id": f"call_{tool_name}",
"type": "function",
"function": {
"name": tool_name,
"arguments": handler.get("arguments", "{}")
}
}]
}
}]
}
# Default: return first available response or empty
if self.responses:
response = self.responses[0]
self.call_count += 1
return response
return {
"choices": [{
"message": {
"content": "How can I help you with your trading bot?",
"role": "assistant",
}
}]
}

View File

@@ -0,0 +1,172 @@
"""Tool registry and definitions for the conversational agent."""
from typing import Dict, Any, List
TOOL_REGISTRY: Dict[str, Any] = {
"randebu": [
{
"name": "backtest",
"description": "Run strategy backtest",
"category": "Randebu Built-in",
"command": "/backtest",
"details": {
"description": "Run a backtest to evaluate how the current trading strategy would have performed historically.",
"usage": "/backtest [token_address] [--timeframe 1d|4h|1h|15m] [--start YYYY-MM-DD] [--end YYYY-MM-DD]",
"example": "Run a backtest on PEPE for the last 30 days",
},
},
{
"name": "simulate",
"description": "Start/stop simulation",
"category": "Randebu Built-in",
"command": "/simulate",
"details": {
"description": "Start or stop trading simulations that run on real-time klines.",
"usage": "/simulate start|stop|status|results [token_address]",
"example": "Start a simulation on PEPE",
},
},
{
"name": "strategy",
"description": "View/update strategy",
"category": "Randebu Built-in",
"command": "/strategy",
"details": {
"description": "View your current trading strategy or update it with new parameters.",
"usage": "Describe your strategy in plain English, e.g., 'Buy PEPE when price drops 5%'",
"example": "Buy PEPE when it drops 10% within 1 hour",
},
},
{
"name": "create_bot",
"description": "Create a new trading bot",
"category": "Randebu Built-in",
"command": None,
"details": {
"description": "Create a new trading bot linked to the current conversation.",
"usage": "create_bot <name> [--strategy <strategy_desc>]",
"example": "create_bot MyBot --strategy Buy PEPE when it drops 5%",
},
},
{
"name": "list_bots",
"description": "List your trading bots",
"category": "Randebu Built-in",
"command": None,
"details": {
"description": "List all trading bots you own.",
"usage": "list_bots",
"example": "list_bots",
},
},
{
"name": "set_bot",
"description": "Set bot for this conversation",
"category": "Randebu Built-in",
"command": None,
"details": {
"description": "Associate a bot with the current conversation.",
"usage": "set_bot <bot_id>",
"example": "set_bot abc-123-def",
},
},
{
"name": "get_bot_info",
"description": "Get current bot details",
"category": "Randebu Built-in",
"command": None,
"details": {
"description": "Get details of the current bot for display in the right pane.",
"usage": "get_bot_info [bot_id]",
"example": "get_bot_info abc-123-def",
},
},
],
"ave": [
{
"name": "search",
"description": "Token search",
"category": "AVE Cloud Skills",
"command": "/search",
"details": {
"description": "Find tokens by keyword, symbol, or contract address on BSC.",
"usage": "search <keyword> [--chain bsc] [--limit 20]",
"example": "search PEPE\nsearch 0x1234... --chain bsc",
},
},
{
"name": "trending",
"description": "Popular tokens",
"category": "AVE Cloud Skills",
"command": "/trending",
"details": {
"description": "Get list of trending/popular tokens on BSC.",
"usage": "trending [--chain bsc] [--limit 20]",
"example": "trending --chain bsc\ntrending --limit 10",
},
},
{
"name": "risk",
"description": "Honeypot detection",
"category": "AVE Cloud Skills",
"command": "/risk",
"details": {
"description": "Get risk analysis for a token contract including honeypot assessment.",
"usage": "risk <token_address> [--chain bsc]",
"example": "risk 0x6982508145454Ce125dDE157d8d64a26D53f60a2",
},
},
{
"name": "token",
"description": "Token details",
"category": "AVE Cloud Skills",
"command": "/token",
"details": {
"description": "Get detailed information about a specific token including price, market cap, and pairs.",
"usage": "token <address> [--chain bsc]",
"example": "token 0x6982508145454Ce125dDE157d8d64a26D53f60a2",
},
},
{
"name": "price",
"description": "Batch prices",
"category": "AVE Cloud Skills",
"command": "/price",
"details": {
"description": "Get current price(s) for multiple tokens.",
"usage": "price <token_id>,<token_id>,... (e.g., PEPE-bsc,TRUMP-bsc)",
"example": "price PEPE-bsc,TRUMP-bsc",
},
},
],
}
SKILL_EMOJIS: Dict[str, str] = {
"backtest": "📊",
"simulate": "🎮",
"strategy": "📝",
"search": "🔍",
"trending": "📈",
"risk": "📉",
"token": "🪙",
"price": "💰",
}
def get_tool_registry() -> Dict[str, Any]:
"""Return the tool registry for slash command help."""
return TOOL_REGISTRY
def get_tools_by_category(category: str) -> List[Dict[str, Any]]:
"""Get tools filtered by category."""
return TOOL_REGISTRY.get(category, [])
def get_tool_by_name(tool_name: str) -> Dict[str, Any]:
"""Get a tool by its name."""
for category in ["randebu", "ave"]:
for tool in TOOL_REGISTRY.get(category, []):
if tool["name"].lower() == tool_name.lower():
return tool
return None

View File

@@ -23,10 +23,9 @@ class AveCloudClient:
chain: Optional[str] = None, chain: Optional[str] = None,
limit: int = 20, limit: int = 20,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
url = f"{self.DATA_API_URL}/v2/tokens" # Use trending endpoint which supports chain filter
params = {"limit": limit} url = f"{self.DATA_API_URL}/v2/tokens/trending"
if query: params = {"limit": min(limit, 100)} # API returns max 100
params["query"] = query
if chain: if chain:
params["chain"] = chain params["chain"] = chain
@@ -36,8 +35,18 @@ class AveCloudClient:
) )
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
if data.get("status") == 200: if data.get("status") == 1: # 1 = SUCCESS
return data.get("data", []) 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}") raise Exception(f"Failed to fetch tokens: {data}")
async def get_batch_prices(self, token_ids: List[str]) -> Dict[str, Dict[str, Any]]: async def get_batch_prices(self, token_ids: List[str]) -> Dict[str, Dict[str, Any]]:
@@ -73,6 +82,10 @@ class AveCloudClient:
start_time: Optional[int] = None, start_time: Optional[int] = None,
end_time: Optional[int] = None, end_time: Optional[int] = None,
) -> List[Dict[str, Any]]: ) -> 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}" url = f"{self.DATA_API_URL}/v2/klines/token/{token_id}"
params = {"interval": interval, "limit": limit} params = {"interval": interval, "limit": limit}
if start_time: if start_time:
@@ -86,8 +99,9 @@ class AveCloudClient:
) )
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
if data.get("status") == 200: # AVE API returns status: 1 for success, not 200
return data.get("data", []) if data.get("status") == 1:
return data.get("data", {}).get("points", [])
raise Exception(f"Failed to fetch klines: {data}") raise Exception(f"Failed to fetch klines: {data}")
async def get_token_price(self, token_id: str) -> Optional[Dict[str, Any]]: async def get_token_price(self, token_id: str) -> Optional[Dict[str, Any]]:
@@ -101,7 +115,7 @@ class AveCloudClient:
) )
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
if data.get("status") == 200: if data.get("status") == 1:
prices = data.get("data", {}) prices = data.get("data", {})
return prices.get(token_id) return prices.get(token_id)
return None return None

View File

@@ -28,9 +28,13 @@ class BacktestEngine:
self.position = 0.0 self.position = 0.0
self.position_token = "" self.position_token = ""
self.entry_price: Optional[float] = None 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.entry_time: Optional[int] = None
self.trades: List[Dict[str, Any]] = [] self.trades: List[Dict[str, Any]] = []
self.running = False 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]:
self.running = True self.running = True
@@ -38,20 +42,28 @@ class BacktestEngine:
started_at = datetime.utcnow() started_at = datetime.utcnow()
try: try:
token = self.config.get("token", "")
chain = self.config.get("chain", "bsc") chain = self.config.get("chain", "bsc")
timeframe = self.config.get("timeframe", "1h") timeframe = self.config.get("timeframe", "1h")
start_date = self.config.get("start_date", "") start_date = self.config.get("start_date", "")
end_date = self.config.get("end_date", "") end_date = self.config.get("end_date", "")
token_id = ( # Get token address from strategy config (saved when user confirmed token)
f"{token}-{chain}" token_address = None
if token and not token.endswith(f"-{chain}") token_symbol = None
else token
)
if not token_id or token_id == f"-{chain}": # Try to get from conditions first
raise ValueError("Token ID is required") 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 start_ts = None
end_ts = None end_ts = None
@@ -76,6 +88,60 @@ class BacktestEngine:
end_time=end_ts, end_time=end_ts,
) )
if not klines:
self.status = "failed"
self.results = {"error": "No kline data available"}
return self.results
# Debug: log first and last few klines to verify price data
print(f"DEBUG BacktestEngine: Got {len(klines)} klines for {token_id}")
if klines:
print(f"DEBUG BacktestEngine: First kline: {klines[0]}")
print(f"DEBUG BacktestEngine: Last kline: {klines[-1]}")
# Validate Kline data - check for obviously wrong prices
first_close = float(klines[0].get('close', 0))
last_close = float(klines[-1].get('close', 0))
print(f"DEBUG BacktestEngine: first_close={first_close}, last_close={last_close}")
# If price changes by more than 1000x, data is likely wrong
if first_close > 0 and last_close > 0:
price_ratio = max(first_close, last_close) / min(first_close, last_close)
print(f"DEBUG BacktestEngine: price_ratio={price_ratio:.0f}x")
if price_ratio > 1000:
self.status = "failed"
self.results = {
"error": f"Kline data appears incorrect. Price changed by {price_ratio:.0f}x during the period. "
f"First price: ${first_close:.8f}, Last price: ${last_close:.8f}. "
f"This may be due to incorrect token data from the API. Please try a different token or timeframe."
}
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: if not klines:
self.status = "failed" self.status = "failed"
self.results = {"error": "No kline data available"} self.results = {"error": "No kline data available"}
@@ -98,41 +164,73 @@ class BacktestEngine:
return self.results return self.results
async def _process_klines(self, klines: List[Dict[str, Any]]): async def _process_klines(self, klines: List[Dict[str, Any]]):
self.total_klines = len(klines)
# Debug: log strategy config
print(f"DEBUG _process_klines: {len(klines)} klines to process")
print(f"DEBUG _process_klines: conditions = {self.conditions}")
print(f"DEBUG _process_klines: actions = {self.actions}")
print(f"DEBUG _process_klines: stop_loss_percent = {self.stop_loss_percent}")
print(f"DEBUG _process_klines: take_profit_percent = {self.take_profit_percent}")
# Count price drops for debugging
dip_opportunities = 0
for i, kline in enumerate(klines): for i, kline in enumerate(klines):
if not self.running: if not self.running:
break break
self.progress = int((i / self.total_klines) * 100) if self.total_klines > 0 else 0
price = float(kline.get("close", 0)) price = float(kline.get("close", 0))
if price <= 0: if price <= 0:
continue continue
self.last_kline_price = price # Track last price for mark to market
timestamp = kline.get("timestamp", 0) timestamp = kline.get("timestamp", 0)
if self.position > 0 and self.entry_price is not None: if self.position > 0 and self.entry_price is not None:
exit_info = self._check_risk_management(price, timestamp) exit_info = self._check_risk_management(price, timestamp)
if exit_info: if exit_info:
print(f"DEBUG: Kline {i} - Risk exit triggered: {exit_info['reason']} at price {price}")
await self._execute_risk_exit(price, timestamp, exit_info) await self._execute_risk_exit(price, timestamp, exit_info)
continue continue
# Check each condition
for condition in self.conditions: for condition in self.conditions:
if self._check_condition(condition, klines, i, price): cond_result = self._check_condition(condition, klines, i, price)
if cond_result:
dip_opportunities += 1
if self.position == 0:
print(f"DEBUG: Kline {i} - BUY condition triggered: {condition['type']} at price {price}")
await self._execute_actions(price, timestamp, condition) await self._execute_actions(price, timestamp, condition)
break break
print(f"DEBUG _process_klines: Total dip opportunities: {dip_opportunities}")
@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( def _check_risk_management(
self, current_price: float, timestamp: int self, current_price: float, timestamp: int
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
if self.position <= 0 or self.entry_price is None: if self.position <= 0 or self.average_entry_price is None:
return None return None
if self.stop_loss_percent is not None: if self.stop_loss_percent is not None:
stop_loss_price = self.entry_price * (1 - self.stop_loss_percent / 100) stop_loss_price = self.average_entry_price * (1 - self.stop_loss_percent / 100)
if current_price <= stop_loss_price: if current_price <= stop_loss_price:
return {"reason": "stop_loss", "price": stop_loss_price} return {"reason": "stop_loss", "price": stop_loss_price}
if self.take_profit_percent is not None: if self.take_profit_percent is not None:
take_profit_price = self.entry_price * (1 + self.take_profit_percent / 100) take_profit_price = self.average_entry_price * (1 + self.take_profit_percent / 100)
if current_price >= take_profit_price: # 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 {"reason": "take_profit", "price": take_profit_price}
return None return None
@@ -173,6 +271,7 @@ class BacktestEngine:
) )
self.position = 0 self.position = 0
self.entry_price = None self.entry_price = None
self.cost_basis = 0.0
self.entry_time = None self.entry_time = None
def _check_condition( def _check_condition(
@@ -195,6 +294,9 @@ class BacktestEngine:
if prev_price <= 0: if prev_price <= 0:
return False return False
drop_pct = ((prev_price - current_price) / prev_price) * 100 drop_pct = ((prev_price - current_price) / prev_price) * 100
# Debug first few to see what's happening
if current_idx < 5 or drop_pct >= threshold:
print(f"DEBUG _check_condition: idx={current_idx}, prev={prev_price}, curr={current_price}, drop={drop_pct:.4f}%, threshold={threshold}, trigger={drop_pct >= threshold}")
return drop_pct >= threshold return drop_pct >= threshold
elif cond_type == "price_rise": elif cond_type == "price_rise":
@@ -237,18 +339,23 @@ class BacktestEngine:
amount = self.current_balance * (amount_percent / 100) amount = self.current_balance * (amount_percent / 100)
if action_type == "buy" and self.current_balance >= amount: if action_type == "buy" and self.current_balance >= amount:
self.position += amount / price # Buy if we have funds available (supports DCA - buy more on each dip)
# position > 0 just means we already have some tokens, we can still buy more
quantity = amount / price
self.position += quantity
self.current_balance -= amount self.current_balance -= amount
self.cost_basis += amount # Track total cost for average price
self.position_token = token self.position_token = token
self.entry_price = price self.entry_price = price # Keep last entry price for reference
self.entry_time = timestamp self.entry_time = timestamp
print(f"DEBUG _execute_actions: BUY - amount=${amount:.2f}, price={price}, quantity={quantity}, position={self.position}")
self.trades.append( self.trades.append(
{ {
"type": "buy", "type": "buy",
"token": token, "token": token,
"price": price, "price": price,
"amount": amount, "amount": amount,
"quantity": amount / price, "quantity": quantity,
"timestamp": timestamp, "timestamp": timestamp,
} }
) )
@@ -268,21 +375,35 @@ class BacktestEngine:
) )
elif action_type == "sell" and self.position > 0: elif action_type == "sell" and self.position > 0:
sell_amount = self.position * price # Sell amount_percent of current position (default 100% if not specified)
sell_percent = action.get("amount_percent", 100) / 100.0
sell_quantity = self.position * sell_percent
sell_amount = sell_quantity * price
self.current_balance += sell_amount self.current_balance += sell_amount
# Proportionally reduce cost_basis
sold_cost_basis = self.cost_basis * sell_percent
self.cost_basis -= sold_cost_basis
print(f"DEBUG _execute_actions: SELL - position={self.position}, sell_percent={sell_percent*100}%, quantity={sell_quantity}, price={price}, sell_amount=${sell_amount:.2f}")
self.trades.append( self.trades.append(
{ {
"type": "sell", "type": "sell",
"token": self.position_token, "token": self.position_token,
"price": price, "price": price,
"amount": sell_amount, "amount": sell_amount,
"quantity": self.position, "quantity": sell_quantity,
"timestamp": timestamp, "timestamp": timestamp,
"exit_reason": "manual", "exit_reason": "manual",
} }
) )
# Update remaining position
self.position -= sell_quantity
if self.position <= 0.00000001: # Account for floating point
self.position = 0 self.position = 0
self.entry_price = None self.entry_price = None
self.cost_basis = 0.0
self.entry_time = None self.entry_time = None
self.signals.append( self.signals.append(
{ {
@@ -300,15 +421,49 @@ class BacktestEngine:
) )
def _calculate_metrics(self): def _calculate_metrics(self):
final_balance = self.current_balance + ( # Debug: log all trades for analysis
self.position * self.trades[-1]["price"] print(f"DEBUG _calculate_metrics: {len(self.trades)} total trades")
if self.trades and self.position > 0 buy_trades = [t for t in self.trades if t["type"] == "buy"]
else 0 sell_trades = [t for t in self.trades if t["type"] == "sell"]
) print(f" Buy trades: {len(buy_trades)}")
print(f" Sell trades: {len(sell_trades)}")
if buy_trades:
print(f" First buy: amount=${buy_trades[0]['amount']:.2f}, price={buy_trades[0]['price']}, quantity={buy_trades[0]['quantity']}")
print(f" Last buy: amount=${buy_trades[-1]['amount']:.2f}, price={buy_trades[-1]['price']}, quantity={buy_trades[-1]['quantity']}")
if sell_trades:
print(f" First sell: amount=${sell_trades[0]['amount']:.2f}, price={sell_trades[0]['price']}, exit_reason={sell_trades[0].get('exit_reason')}")
print(f" Last sell: amount=${sell_trades[-1]['amount']:.2f}, price={sell_trades[-1]['price']}, exit_reason={sell_trades[-1].get('exit_reason')}")
# 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
# Debug logging
print(f"DEBUG _calculate_metrics:")
print(f" initial_balance: {self.initial_balance}")
print(f" current_balance: {self.current_balance}")
print(f" position: {self.position}")
print(f" position_price: {position_price}")
print(f" last_kline_price: {self.last_kline_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
print(f" final_balance calculated: {final_balance}")
total_return = ( total_return = (
(final_balance - self.initial_balance) / self.initial_balance (final_balance - self.initial_balance) / self.initial_balance
) * 100 ) * 100
print(f" total_return calculated: {total_return}%")
buy_trades = [t for t in self.trades if t["type"] == "buy"] buy_trades = [t for t in self.trades if t["type"] == "buy"]
sell_trades = [t for t in self.trades if t["type"] == "sell"] sell_trades = [t for t in self.trades if t["type"] == "sell"]
total_trades = len(buy_trades) + len(sell_trades) total_trades = len(buy_trades) + len(sell_trades)
@@ -331,18 +486,23 @@ class BacktestEngine:
for trade in self.trades: for trade in self.trades:
if trade["type"] == "buy": if trade["type"] == "buy":
running_position = trade["quantity"] running_position += trade["quantity"] # Add to existing position (DCA)
running_balance = trade["amount"] running_balance -= trade["amount"] # Subtract amount spent
current_token = trade["token"] current_token = trade["token"]
last_price = trade["price"] last_price = trade["price"]
else: else: # sell
running_balance = trade["amount"] running_balance += trade["amount"] # Add amount received
running_position = 0 running_position = 0 # Close entire position
last_price = trade["price"] last_price = trade["price"]
portfolio_value = running_balance + (running_position * last_price) portfolio_value = running_balance + (running_position * last_price)
portfolio_values.append(portfolio_value) 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_value = self.initial_balance
max_drawdown = 0.0 max_drawdown = 0.0
for value in portfolio_values: for value in portfolio_values:
@@ -380,10 +540,13 @@ class BacktestEngine:
"sharpe_ratio": round(sharpe_ratio, 2), "sharpe_ratio": round(sharpe_ratio, 2),
"final_balance": round(final_balance, 2), "final_balance": round(final_balance, 2),
"signals": self.signals, "signals": self.signals,
"trades": self.trades, # Include trades in results for storage
} }
async def stop(self): async def stop(self):
self.running = False self.running = False
self.progress = 0
self.total_klines = 0
self.status = "stopped" self.status = "stopped"
self._calculate_metrics() self._calculate_metrics()
@@ -393,4 +556,13 @@ class BacktestEngine:
"status": self.status, "status": self.status,
"results": self.results, "results": self.results,
"signals": self.signals, "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

@@ -0,0 +1,95 @@
import os
from datetime import datetime, timedelta
from sqlalchemy import func
from fastapi import HTTPException
from ..db.models import Message, AnonymousUser
MAX_CHATS_PER_5HOURS = int(os.getenv("MAX_CHATS_PER_5HOURS", "500"))
MAX_ANONYMOUS_CHATS = 50
MAX_ANONYMOUS_BOTS = 1
MAX_ANONYMOUS_BACKTESTS = 1
class RateLimiter:
@staticmethod
def check_system_limit(db):
cutoff = datetime.utcnow() - timedelta(hours=5)
count = (
db.query(func.count(Message.id))
.filter(Message.created_at >= cutoff)
.scalar()
)
if count >= MAX_CHATS_PER_5HOURS:
raise HTTPException(
status_code=429,
detail="Rate limited from the agent service. Please come back later.",
)
@staticmethod
def check_anonymous_limit(db, anonymous_token: str):
anon = (
db.query(AnonymousUser).filter(AnonymousUser.id == anonymous_token).first()
)
if anon and anon.chat_count >= MAX_ANONYMOUS_CHATS:
raise HTTPException(
status_code=403,
detail="You've reached the limit. Please create an account to continue.",
)
return anon
@staticmethod
def check_anonymous_bot_limit(db, anonymous_token: str):
anon = (
db.query(AnonymousUser).filter(AnonymousUser.id == anonymous_token).first()
)
if anon and anon.bot_created:
raise HTTPException(
status_code=403,
detail="You've reached the limit. Please create an account to continue.",
)
@staticmethod
def check_anonymous_backtest_limit(db, anonymous_token: str):
anon = (
db.query(AnonymousUser).filter(AnonymousUser.id == anonymous_token).first()
)
if anon and anon.backtest_count >= MAX_ANONYMOUS_BACKTESTS:
raise HTTPException(
status_code=403,
detail="You've reached the limit. Please create an account to continue.",
)
@staticmethod
def increment_chat_count(db, anonymous_token: str):
anon = (
db.query(AnonymousUser).filter(AnonymousUser.id == anonymous_token).first()
)
if anon:
anon.chat_count += 1
db.commit()
@staticmethod
def set_bot_created(db, anonymous_token: str):
anon = (
db.query(AnonymousUser).filter(AnonymousUser.id == anonymous_token).first()
)
if anon:
anon.bot_created = True
db.commit()
@staticmethod
def increment_backtest_count(db, anonymous_token: str):
anon = (
db.query(AnonymousUser).filter(AnonymousUser.id == anonymous_token).first()
)
if anon:
anon.backtest_count += 1
db.commit()

View File

@@ -3,6 +3,7 @@ import asyncio
import logging import logging
from datetime import datetime from datetime import datetime
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional
from ..ave.client import AveCloudClient from ..ave.client import AveCloudClient
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -26,23 +27,66 @@ class SimulateEngine:
self.risk_management = self.strategy_config.get("risk_management", {}) self.risk_management = self.strategy_config.get("risk_management", {})
self.stop_loss_percent = self.risk_management.get("stop_loss_percent") self.stop_loss_percent = self.risk_management.get("stop_loss_percent")
self.take_profit_percent = self.risk_management.get("take_profit_percent") self.take_profit_percent = self.risk_management.get("take_profit_percent")
self.check_interval = config.get("check_interval", 60)
self.duration_seconds = config.get("duration_seconds", 3600) # Kline-based settings
self.kline_interval = config.get("kline_interval", "1m")
self.max_candles = config.get("max_candles", 100) # Limit candles to simulate real-time
# Delay between candles (in seconds) to simulate real-time
# 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.auto_execute = config.get("auto_execute", False)
self.token = config.get("token", "") self.token = config.get("token", "")
self.chain = config.get("chain", "bsc") self.chain = config.get("chain", "bsc")
self.running = False self.running = False
self.started_at: Optional[datetime] = None self.started_at: Optional[datetime] = None
self.last_price: Optional[float] = None
# Price tracking (for conditions)
self.last_close: Optional[float] = None
self.last_volume: Optional[float] = None self.last_volume: Optional[float] = None
# Position tracking (for risk management)
self.position: float = 0.0 self.position: float = 0.0
self.position_token: str = "" self.position_token: str = ""
self.entry_price: Optional[float] = None self.entry_price: Optional[float] = None
self.entry_time: Optional[int] = None self.entry_time: Optional[int] = None
# Portfolio
self.current_balance: float = config.get("initial_balance", 10000.0) self.current_balance: float = config.get("initial_balance", 10000.0)
self.trades: List[Dict[str, Any]] = [] self.trades: List[Dict[str, Any]] = []
# Error tracking
self.errors: List[str] = [] 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]: async def run(self) -> Dict[str, Any]:
self.running = True self.running = True
self.status = "running" self.status = "running"
@@ -59,72 +103,174 @@ class SimulateEngine:
self.results = {"error": "Token ID is required"} self.results = {"error": "Token ID is required"}
return self.results return self.results
end_time = datetime.utcnow().timestamp() + self.duration_seconds
try: try:
while self.running and datetime.utcnow().timestamp() < end_time: # Step 1: Fetch klines (only once for simulation)
try: self.klines = await self._fetch_klines(token_id)
price_data = await self.ave_client.get_token_price(token_id)
if price_data:
current_price = float(price_data.get("price", 0))
current_volume = float(price_data.get("volume", 0))
if current_price > 0: if not self.klines:
await self._check_conditions( self.status = "failed"
current_price, current_volume, price_data self.results = {"error": "No kline data available"}
) return self.results
self.last_price = current_price logger.info(f"Fetched {len(self.klines)} klines for {token_id}")
self.last_volume = current_volume
except Exception as e: # Step 2: Process candles (with limit)
logger.warning(f"Failed to get price for {token_id}: {e}") candles_processed = 0
self.errors.append(f"Price fetch failed for {token_id}: {str(e)}") self.total_candles = min(len(self.klines), self.max_candles)
continue self.current_candle_index = 0
for _ in range(self.check_interval): for i, candle in enumerate(self.klines):
if not self.running: if not self.running:
break break
await asyncio.sleep(1) 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)
if self.running:
self.status = "completed" self.status = "completed"
else:
self.status = "stopped"
except Exception as e: except Exception as e:
logger.error(f"Simulation error: {e}")
self.status = "failed" self.status = "failed"
self.results = {"error": str(e)} self.results = {"error": str(e)}
self.errors.append(str(e))
self.results = self.results or {} self.results = self.results or {}
self.results["total_signals"] = len(self.signals) self.results["total_signals"] = len(self.signals)
self.results["total_trades"] = len(self.trades)
self.results["total_errors"] = len(self.errors) self.results["total_errors"] = len(self.errors)
self.results["errors"] = self.errors self.results["errors"] = self.errors
self.results["signals"] = self.signals 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["started_at"] = self.started_at
self.results["ended_at"] = datetime.utcnow() self.results["ended_at"] = datetime.utcnow()
return self.results return self.results
async def _check_conditions( async def _fetch_klines(
self, current_price: float, current_volume: float, price_data: Dict[str, Any] self,
): token_id: str,
timestamp = int(datetime.utcnow().timestamp() * 1000) 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: if self.position > 0 and self.entry_price is not None:
exit_info = self._check_risk_management(current_price, timestamp) exit_info = self._check_risk_management(close_price, timestamp)
if exit_info: if exit_info:
await self._execute_risk_exit(current_price, timestamp, 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 return
# Check conditions (only if no open position)
if self.position == 0:
for condition in self.conditions: for condition in self.conditions:
if self._check_condition(condition, current_price, current_volume): if self._check_condition(condition, close_price, volume):
await self._execute_actions(current_price, timestamp, condition) 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 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( def _check_risk_management(
self, current_price: float, timestamp: int self, current_price: float, timestamp: int
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
"""Check if stop loss or take profit is triggered."""
if self.position <= 0 or self.entry_price is None: if self.position <= 0 or self.entry_price is None:
return None return None
@@ -143,16 +289,24 @@ class SimulateEngine:
async def _execute_risk_exit( async def _execute_risk_exit(
self, price: float, timestamp: int, exit_info: Dict[str, Any] self, price: float, timestamp: int, exit_info: Dict[str, Any]
): ):
"""Execute stop loss or take profit."""
if self.position <= 0: if self.position <= 0:
return return
reason = exit_info["reason"] 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( self.trades.append(
{ {
"type": "sell", "type": "sell",
"token": self.position_token, "token": self.position_token,
"price": price, "price": price,
"quantity": self.position, "quantity": quantity,
"amount": sale_proceeds,
"timestamp": timestamp, "timestamp": timestamp,
"exit_reason": reason, "exit_reason": reason,
} }
@@ -181,32 +335,34 @@ class SimulateEngine:
current_price: float, current_price: float,
current_volume: float, current_volume: float,
) -> bool: ) -> bool:
"""Check if a condition is met based on price movement."""
cond_type = condition.get("type", "") cond_type = condition.get("type", "")
threshold = condition.get("threshold", 0) threshold = condition.get("threshold", 0)
price_level = condition.get("price")
direction = condition.get("direction", "above")
if cond_type == "price_drop": if cond_type == "price_drop":
if self.last_price is None or self.last_price <= 0: # Price dropped by threshold % from last close
if self.last_close is None or self.last_close <= 0:
return False return False
drop_pct = ((self.last_price - current_price) / self.last_price) * 100 drop_pct = ((self.last_close - current_price) / self.last_close) * 100
return drop_pct >= threshold return drop_pct >= threshold
elif cond_type == "price_rise": elif cond_type == "price_rise":
if self.last_price is None or self.last_price <= 0: # Price rose by threshold % from last close
if self.last_close is None or self.last_close <= 0:
return False return False
rise_pct = ((current_price - self.last_price) / self.last_price) * 100 rise_pct = ((current_price - self.last_close) / self.last_close) * 100
return rise_pct >= threshold return rise_pct >= threshold
elif cond_type == "volume_spike": elif cond_type == "volume_spike":
# Volume increased significantly
if self.last_volume is None or self.last_volume <= 0: if self.last_volume is None or self.last_volume <= 0:
return False return False
volume_increase = ( volume_increase = ((current_volume - self.last_volume) / self.last_volume) * 100
(current_volume - self.last_volume) / self.last_volume
) * 100
return volume_increase >= threshold return volume_increase >= threshold
elif cond_type == "price_level": elif cond_type == "price_level":
price_level = condition.get("price")
direction = condition.get("direction", "above")
if price_level is None: if price_level is None:
return False return False
if direction == "above": if direction == "above":
@@ -219,6 +375,7 @@ class SimulateEngine:
async def _execute_actions( async def _execute_actions(
self, price: float, timestamp: int, matched_condition: Dict[str, Any] 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) token = matched_condition.get("token", self.token)
reasoning = f"Condition {matched_condition.get('type')} triggered" reasoning = f"Condition {matched_condition.get('type')} triggered"
@@ -227,18 +384,21 @@ class SimulateEngine:
if action_type == "buy": if action_type == "buy":
amount_percent = action.get("amount_percent", 10) amount_percent = action.get("amount_percent", 10)
amount = self.current_balance * (amount_percent / 100) amount = self.current_balance * (amount_percent / 100)
self.position += amount / price quantity = amount / price
self.position += quantity
self.position_token = token self.position_token = token
self.entry_price = price self.entry_price = price
self.entry_time = timestamp self.entry_time = timestamp
self.current_balance -= amount self.current_balance -= amount
self.trades.append( self.trades.append(
{ {
"type": "buy", "type": "buy",
"token": token, "token": token,
"price": price, "price": price,
"amount": amount, "amount": amount,
"quantity": amount / price, "quantity": quantity,
"timestamp": timestamp, "timestamp": timestamp,
} }
) )
@@ -258,11 +418,13 @@ class SimulateEngine:
self.signals.append(signal) self.signals.append(signal)
async def stop(self): def stop(self):
"""Stop the simulation."""
self.running = False self.running = False
self.status = "stopped" self.status = "stopped"
def get_results(self) -> Dict[str, Any]: def get_results(self) -> Dict[str, Any]:
"""Get simulation results."""
return { return {
"id": self.run_id, "id": self.run_id,
"status": self.status, "status": self.status,
@@ -271,4 +433,5 @@ class SimulateEngine:
} }
def get_signals(self) -> List[Dict[str, Any]]: def get_signals(self) -> List[Dict[str, Any]]:
"""Get current signals."""
return self.signals return self.signals

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,609 @@
"""Tests for ConversationalAgent using mock client."""
import pytest
import sys
import os
# Add parent directory to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.services.ai_agent.agent import ConversationalAgent
from app.services.ai_agent.mock_client import MockMiniMaxClient, MockMiniMaxClientWithToolCall
class TestConversationalAgent:
"""Test ConversationalAgent with mocked MiniMax API."""
def test_greeting_response(self):
"""Test that agent responds to greeting."""
mock = MockMiniMaxClient()
mock.add_response({
"choices": [{
"message": {
"content": "Hello! How can I help you with your trading today?",
"role": "assistant",
}
}]
})
agent = ConversationalAgent(client=mock)
result = agent.chat("Hello")
assert "Hello" in result.get("response", "")
assert mock.call_count == 1
def test_api_error_returns_error_message(self):
"""Test that when API returns an error (like 529 overloaded), we return an error message."""
mock = MockMiniMaxClient()
# API returns an error on all 3 retry attempts
for _ in range(3):
mock.add_response({
"error": "API returned 529: Server overloaded"
})
agent = ConversationalAgent(client=mock)
result = agent.chat("Hello")
# Should return error message, not empty string
response = result.get("response", "")
print(f"Response: {response}")
print(f"Call count: {mock.call_count}")
assert response != "", "Response should not be empty when API errors"
assert "trouble" in response.lower() or "error" in response.lower() or "sorry" in response.lower(), \
f"Response should mention error: {response}"
assert result.get("success") == False
def test_api_empty_choices_returns_error_message(self):
"""Test that when API returns choices=None (like 520 error), we return an error message."""
mock = MockMiniMaxClient()
# API returns empty choices on all 3 retry attempts
for _ in range(3):
mock.add_response({
"id": "test-id",
"choices": None, # Empty choices = API error
"model": "MiniMax-M2.7",
"base_resp": {"status_code": 1000, "status_msg": "unknown error, 520"}
})
agent = ConversationalAgent(client=mock)
result = agent.chat("Hello")
# Should return error message, not empty string
response = result.get("response", "")
print(f"Response: {response}")
print(f"Call count: {mock.call_count}")
assert response != "", "Response should not be empty when API returns empty choices"
assert "trouble" in response.lower() or "error" in response.lower() or "sorry" in response.lower(), \
f"Response should mention error: {response}"
assert result.get("success") == False
def test_tool_call_list_bots(self):
"""Test that agent calls list_bots tool when asked about bots."""
mock = MockMiniMaxClient()
# First call: model decides to call list_bots
mock.add_response({
"choices": [{
"finish_reason": "tool_calls",
"message": {
"content": "",
"role": "assistant",
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {
"name": "list_bots",
"arguments": "{}"
}
}]
}
}]
})
# Second call: after tool result, model gives final response
mock.add_response({
"choices": [{
"message": {
"content": "You don't have any bots yet. Would you like to create one?",
"role": "assistant",
}
}]
})
# Pass anonymous_token so _execute_list_bots doesn't return error
agent = ConversationalAgent(client=mock, anonymous_token="test-token")
result = agent.chat("What bots do I have?")
assert mock.call_count == 2
# The response should be from the second call
assert "bot" in result.get("response", "").lower()
def test_tool_result_with_empty_content_returns_tool_result(self):
"""Test that if second API call returns empty content but has tool_calls, we fallback to tool result."""
mock = MockMiniMaxClient()
# First call: model calls list_bots
mock.add_response({
"choices": [{
"finish_reason": "tool_calls",
"message": {
"content": "",
"role": "assistant",
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {
"name": "list_bots",
"arguments": "{}"
}
}]
}
}]
})
# Second call: model calls ANOTHER tool (not providing text)
mock.add_response({
"choices": [{
"finish_reason": "tool_calls",
"message": {
"content": "\n", # Whitespace only
"role": "assistant",
"tool_calls": [{
"id": "call_2",
"type": "function",
"function": {
"name": "get_bot_info",
"arguments": "{}"
}
}]
}
}]
})
agent = ConversationalAgent(client=mock)
result = agent.chat("What bots do I have?")
# Should fallback to the tool result text, not empty string
assert result.get("response", "") != ""
def test_content_with_tool_calls_returns_content(self):
"""Test that if second API call returns BOTH content AND tool_calls, we return the content.
This tests the exact scenario from production:
- Tool result: 'Backtest failed: Token address not found...'
- Model response: 'Got it! Running the backtest now.' (with tool_calls)
- Expected: Since tool result is an ERROR, we should return the error, NOT model content
"""
mock = MockMiniMaxClient()
# First call: model calls run_backtest
mock.add_response({
"choices": [{
"finish_reason": "tool_calls",
"message": {
"content": "",
"role": "assistant",
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {
"name": "run_backtest",
"arguments": '{"token_address": "0x..."}'
}
}]
}
}]
})
# Second call: model returns misleading positive content AND has tool_calls
# But tool result was an ERROR, so we should return the error
mock.add_response({
"choices": [{
"finish_reason": "tool_calls",
"message": {
"content": "Got it! Running the backtest now.", # Misleading!
"role": "assistant",
"tool_calls": [{
"id": "call_2",
"type": "function",
"function": {
"name": "get_bot_info",
"arguments": "{}"
}
}]
}
}]
})
agent = ConversationalAgent(
client=mock,
conversation_id="test-conv",
anonymous_token="test-token"
)
result = agent.chat("Run backtest on SHIB")
# Since tool result is an error, we should return the ERROR, not model content
response = result.get("response", "")
print(f"Response: '{response}'")
# The key assertion: tool result was an error, so we should return the error
assert "not found" in response.lower() or "error" in response.lower() or "couldn't" in response.lower(), \
f"Expected error from tool result, got: {response}"
assert "Got it" not in response, f"Should NOT return misleading positive content"
def test_content_without_tool_calls_returns_content(self):
"""Test that if second API call returns content WITHOUT tool_calls, we return the content.
Note: This test uses list_bots which returns a friendly message, not an error."""
mock = MockMiniMaxClient()
# First call: model calls list_bots
mock.add_response({
"choices": [{
"finish_reason": "tool_calls",
"message": {
"content": "",
"role": "assistant",
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {
"name": "list_bots",
"arguments": '{}'
}
}]
}
}]
})
# Second call: model returns ONLY content, no tool_calls
# Tool result was "You don't have any bots yet" (not an error keyword), use model content
mock.add_response({
"choices": [{
"finish_reason": "stop",
"message": {
"content": "You don't have any bots. Would you like to create one?", # Content only
"role": "assistant",
}
}]
})
agent = ConversationalAgent(
client=mock,
conversation_id="test-conv",
anonymous_token="test-token"
)
result = agent.chat("What bots do I have?")
response = result.get("response", "")
print(f"Response: '{response}'")
assert "You don't have any bots" in response
def test_second_api_call_error_returns_tool_result(self):
"""Test that if second API call (in _send_tool_result_to_model) returns error, we return tool result."""
mock = MockMiniMaxClient()
# First call: model calls run_backtest
mock.add_response({
"choices": [{
"finish_reason": "tool_calls",
"message": {
"content": "",
"role": "assistant",
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {
"name": "run_backtest",
"arguments": '{}'
}
}]
}
}]
})
# Second call: API returns error on all 3 retry attempts
for _ in range(3):
mock.add_response({
"error": "API returned 529: Server overloaded"
})
agent = ConversationalAgent(
client=mock,
conversation_id="test-conv",
anonymous_token="test-token"
)
result = agent.chat("Run backtest")
# Should fallback to tool result (backtest not found message), not empty string
response = result.get("response", "")
print(f"Response: '{response}'")
print(f"Call count: {mock.call_count}")
assert response != "", "Response should not be empty when second API errors"
# The tool result should be "I couldn't find the bot..."
assert "bot" in response.lower() or "couldn't find" in response.lower(), \
f"Response should contain tool result: {response}"
def test_chained_tool_calls_with_empty_content(self):
"""Test that if model returns empty content but has tool_calls, we execute the next tool.
This is the exact scenario from production:
- User asks about bots
- Model calls list_bots tool
- Tool returns bot list
- Model responds with EMPTY content but has get_bot_info tool call
- We should execute get_bot_info and continue, NOT return empty string
"""
mock = MockMiniMaxClient()
# First call: model decides to call list_bots
mock.add_response({
"choices": [{
"finish_reason": "tool_calls",
"message": {
"content": "",
"role": "assistant",
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {
"name": "list_bots",
"arguments": "{}"
}
}]
}
}]
})
# Second call: model returns EMPTY content but wants to call get_bot_info
# This is the BUG scenario - model just wants to do more tool calls
mock.add_response({
"choices": [{
"finish_reason": "tool_calls",
"message": {
"content": "", # Empty content!
"role": "assistant",
"tool_calls": [{
"id": "call_2",
"type": "function",
"function": {
"name": "get_bot_info",
"arguments": '{"bot_id": "test-bot-123"}'
}
}]
}
}]
})
# Third call: after get_bot_info, model finally returns content
mock.add_response({
"choices": [{
"message": {
"content": "I found your bot 'Test Bot' with 2 strategy conditions configured.",
"role": "assistant",
}
}]
})
agent = ConversationalAgent(client=mock, anonymous_token="test-token")
result = agent.chat("Tell me about my bots")
response = result.get("response", "")
print(f"Response: '{response}'")
print(f"Call count: {mock.call_count}")
# Should NOT return empty string - should continue with tool calls
assert response != "", "Response should not be empty when model has more tool calls"
# get_bot_info returns error since bot doesn't exist in DB, but we continued the chain
# (which is the key behavior we're testing - it didn't return empty string)
assert "" in response or "bot" in response.lower(), f"Response should mention bot or error: {response}"
def test_json_response_extraction(self):
"""Test that JSON-formatted responses are properly extracted."""
mock = MockMiniMaxClient()
# Model returns JSON in code block with response field
mock.add_response({
"choices": [{
"message": {
"content": "```json\n{\n \"thinking\": \"The user wants a bot. Creating one now.\",\n \"response\": \"✅ Bot created successfully!\"\n}\n```",
"role": "assistant",
}
}]
})
agent = ConversationalAgent(client=mock)
result = agent.chat("create a bot")
response = result.get("response", "")
print(f"Response: '{response}'")
# Should extract the response field, not show JSON
assert "✅ Bot created successfully!" in response, f"Should contain bot message: {response}"
assert "thinking" not in response.lower() or "json" not in response.lower(), f"Should not contain raw JSON: {response}"
assert "```json" not in response, f"Should not contain code block markers: {response}"
def test_retry_succeeds_on_second_attempt(self):
"""Test that if first API call fails but second succeeds, we use the successful response."""
mock = MockMiniMaxClient()
# First attempt fails with error
mock.add_response({"error": "API returned 529: Server overloaded"})
# Second attempt succeeds
mock.add_response({
"choices": [{
"message": {
"content": "Hello! How can I help you?",
"role": "assistant",
}
}]
})
agent = ConversationalAgent(client=mock)
result = agent.chat("Hello")
response = result.get("response", "")
print(f"Response: '{response}'")
print(f"Call count: {mock.call_count}")
# Should use the successful response from 2nd attempt
assert "Hello" in response
assert "trouble" not in response.lower()
assert mock.call_count == 2 # Two calls: 1 error + 1 success
def test_model_json_response_is_parsed(self):
"""Test that if model returns JSON-formatted response in _send_tool_result_to_model, we extract only the response field."""
mock = MockMiniMaxClient()
# User message triggers tool call
mock.add_response({
"choices": [{
"finish_reason": "tool_calls",
"message": {
"content": "",
"role": "assistant",
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {
"name": "create_bot",
"arguments": '{"name": "TestBot"}'
}
}]
}
}]
})
# Model's second response is JSON (this is the bug scenario)
mock.add_response({
"choices": [{
"message": {
# Model returns JSON instead of plain text
"content": '{"thinking": "Bot created", "response": "Your bot TestBot is ready! What strategy?", "strategy_update": null}',
"role": "assistant",
}
}]
})
agent = ConversationalAgent(
client=mock,
conversation_id="test-conv",
anonymous_token="test-token"
)
result = agent.chat("Create a bot named TestBot")
# The response should NOT contain JSON
response_text = result.get("response", "")
print(f"Response: {response_text}")
# Should NOT contain JSON artifacts
assert "{\"thinking\":" not in response_text
assert "\"strategy_update\":" not in response_text
assert "```json" not in response_text
# Should contain the actual response text
assert "bot TestBot is ready" in response_text or "strategy" in response_text.lower()
def test_create_bot_flow(self):
"""Test the full flow of creating a bot."""
mock = MockMiniMaxClient()
# First call: model asks for bot name
mock.add_response({
"choices": [{
"message": {
"content": "I'd be happy to help you create a trading bot! What would you like to name it?",
"role": "assistant",
}
}]
})
# Second call: user provides name, model calls create_bot
mock.add_response({
"choices": [{
"finish_reason": "tool_calls",
"message": {
"content": "",
"role": "assistant",
"tool_calls": [{
"id": "call_create",
"type": "function",
"function": {
"name": "create_bot",
"arguments": '{"name": "TestBot"}'
}
}]
}
}]
})
# Third call: after create_bot result, model gives PLAIN TEXT response (not JSON)
mock.add_response({
"choices": [{
"message": {
"content": "✅ Your bot 'TestBot' has been created! Now let's set up your trading strategy.",
"role": "assistant",
}
}]
})
agent = ConversationalAgent(
client=mock,
conversation_id="test-conv-123",
anonymous_token="test-token"
)
# First message - greeting
result1 = agent.chat("I want to run a backtest")
assert mock.call_count == 1
# Second message - bot name
result2 = agent.chat("MyBot")
assert mock.call_count == 3 # Two calls due to tool execution
assert "bot" in result2.get("response", "").lower()
class TestMockMiniMaxClient:
"""Test the mock client itself."""
def test_add_and_retrieve_responses(self):
"""Test that responses are returned in order."""
mock = MockMiniMaxClient()
mock.add_response({"result": "first"})
mock.add_response({"result": "second"})
assert mock.chat({}, "") == {"result": "first"}
assert mock.chat({}, "") == {"result": "second"}
def test_calls_are_recorded(self):
"""Test that all calls are recorded."""
mock = MockMiniMaxClient()
mock.add_response({"result": "ok"})
mock.chat(["msg1"], "system")
mock.chat(["msg2"], "system")
assert len(mock.calls) == 2
assert mock.calls[0]["messages"] == ["msg1"]
assert mock.calls[1]["messages"] == ["msg2"]
def test_default_response_when_exhausted(self):
"""Test default response when all predefined responses are used."""
mock = MockMiniMaxClient()
mock.add_response({"result": "only"})
result1 = mock.chat([], "")
result2 = mock.chat([], "") # No more responses
assert result1 == {"result": "only"}
assert "Mock response" in result2["choices"][0]["message"]["content"]
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@@ -0,0 +1,505 @@
import pytest
import asyncio
from datetime import datetime
import sys
import os
# Add the backend directory to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'app'))
from app.services.backtest.engine import BacktestEngine
def create_klines(start_price, num_klines, interval=1.0, volatility=0.01):
"""Helper to create kline data with predictable price movements.
Args:
start_price: Starting price
num_klines: Number of klines to create
interval: Price change per kline (can be positive or negative)
volatility: Random noise factor (0.01 = 1%)
"""
import random
klines = []
price = start_price
base_time = 1704067200 # 2024-01-01 00:00:00 UTC
for i in range(num_klines):
# Add some randomness
noise = (random.random() - 0.5) * 2 * volatility * price
price = max(0.00000001, price + interval + noise)
open_price = price - (random.random() * volatility * price)
close_price = price
high_price = max(open_price, close_price) + (random.random() * volatility * price)
low_price = min(open_price, close_price) - (random.random() * volatility * price)
klines.append({
"open": str(open_price),
"high": str(high_price),
"low": str(low_price),
"close": str(close_price),
"volume": str(1000 + random.random() * 100),
"amount": str(1000 * price),
"time": base_time + (i * 3600) # 1 hour intervals
})
return klines
class MockAveClient:
"""Mock AVE client that returns test klines."""
def __init__(self, klines):
self.klines = klines
async def get_klines(self, token_id, interval, limit, start_time=None, end_time=None):
return self.klines
class TestBacktestEngine:
"""Test cases for BacktestEngine."""
@pytest.fixture
def base_config(self):
"""Base config for backtest."""
return {
"bot_id": "test-bot-123",
"token": "0xtest",
"chain": "bsc",
"timeframe": "1h",
"start_date": "2024-01-01",
"end_date": "2024-01-02",
"ave_api_key": "test-key",
"ave_api_plan": "free",
"initial_balance": 10000.0,
}
@pytest.fixture
def simple_strategy(self):
"""Simple strategy: buy on 1% drop, no auto sell."""
return {
"conditions": [
{"type": "price_drop", "token": "TEST", "token_address": "0xtest", "threshold": 1.0}
],
"actions": [
{"type": "buy", "amount_percent": 10}
],
"risk_management": {}
}
@pytest.fixture
def partial_sell_strategy(self):
"""Strategy with partial sells: buy on 1% drop, sell 50% on rise (via risk management take profit)."""
return {
"conditions": [
{"type": "price_drop", "token": "TEST", "token_address": "0xtest", "threshold": 1.0}
],
"actions": [
{"type": "buy", "amount_percent": 10}
],
"risk_management": {
"take_profit_percent": 1.5 # 1.5% take profit to trigger on price rise
}
}
@pytest.fixture
def stop_loss_strategy(self):
"""Strategy with stop loss and take profit."""
return {
"conditions": [
{"type": "price_drop", "token": "TEST", "token_address": "0xtest", "threshold": 1.0}
],
"actions": [
{"type": "buy", "amount_percent": 10}
],
"risk_management": {
"stop_loss_percent": 5, # 5% stop loss
"take_profit_percent": 10 # 10% take profit
}
}
def test_single_buy_and_hold(self, base_config, simple_strategy):
"""Test buying once and holding (no sell triggers)."""
# Create klines that drop 0.5% each (below 1% threshold, no buy)
# Then rise 0.5% each (still no sell since no position)
klines = create_klines(100, 10, interval=0.5) # Rising trend
config = {**base_config, "strategy_config": simple_strategy}
engine = BacktestEngine(config)
engine.ave_client = MockAveClient(klines)
results = asyncio.run(engine.run())
print(f"Results: {results}")
# Should have 0 trades since price never dropped 1%
assert results.get("total_trades") == 0
assert results.get("final_balance") == 10000.0
def test_multiple_dips_multiple_buys(self, base_config, simple_strategy):
"""Test multiple dips triggering multiple buys (DCA)."""
# Create price that drops 1.5% then rises 0.5%, repeats
# This should trigger buy on each drop
klines = []
price = 100.0
base_time = 1704067200
for i in range(20):
if i % 3 == 0:
# Drop by 1.5%
price = price * 0.985 # 1.5% drop
else:
# Rise by 0.5%
price = price * 1.005
klines.append({
"open": str(price * 0.99),
"high": str(price * 1.01),
"low": str(price * 0.98),
"close": str(price),
"volume": "1000",
"amount": str(1000 * price),
"time": base_time + (i * 3600)
})
config = {**base_config, "strategy_config": simple_strategy}
engine = BacktestEngine(config)
engine.ave_client = MockAveClient(klines)
results = asyncio.run(engine.run())
print(f"Results: {results}")
print(f"Trades: {engine.trades}")
# Should have multiple buys (6-7 dips at 1.5% threshold out of ~7 drops)
assert results.get("total_trades") >= 6
buy_trades = [t for t in engine.trades if t["type"] == "buy"]
assert len(buy_trades) >= 6
# Should end with position > 0 (still holding since no sell triggers)
assert engine.position > 0
def test_partial_sells(self, base_config, partial_sell_strategy):
"""Test partial sells - selling some portion via take profit."""
# Create: drop 1.5% (buy), rise 1.5% (sell via take profit), drop 1.5% (buy), rise 1.5% (sell via take profit)
klines = []
price = 100.0
base_time = 1704067200
for i in range(8):
if i % 2 == 0:
# Even: drop 1.5% (should trigger buy)
price = price * 0.985
else:
# Odd: rise 1.5% (should trigger take profit sell)
price = price * 1.015
klines.append({
"open": str(price * 0.99),
"high": str(price * 1.01),
"low": str(price * 0.98),
"close": str(price),
"volume": "1000",
"amount": str(1000 * price),
"time": base_time + (i * 3600)
})
config = {**base_config, "strategy_config": partial_sell_strategy}
engine = BacktestEngine(config)
engine.ave_client = MockAveClient(klines)
results = asyncio.run(engine.run())
print(f"Results: {results}")
print(f"Trades: {engine.trades}")
# Multiple cycles happen due to price oscillation
# At minimum we should have some trades
assert results.get("total_trades") >= 2
buy_trades = [t for t in engine.trades if t["type"] == "buy"]
sell_trades = [t for t in engine.trades if t["type"] == "sell"]
# Should have executed some trades
assert len(buy_trades) >= 1
assert len(sell_trades) >= 1
# Should have made profit from the sells
assert results.get("total_return") > 0
def test_stop_loss_trigger(self, base_config, stop_loss_strategy):
"""Test stop loss triggers correctly."""
# Create: buy, then continue dropping to trigger stop loss
klines = []
base_time = 1704067200
# Kline 0: reference price (skipped due to idx=0 check)
klines.append({
"open": "100",
"high": "101",
"low": "99",
"close": "100",
"volume": "1000",
"amount": "100000",
"time": base_time
})
# Kline 1: drop triggers buy at 98.5
klines.append({
"open": "100",
"high": "101",
"low": "98",
"close": "98.5", # 1.5% drop from 100
"volume": "1000",
"amount": "98500",
"time": base_time + 3600
})
# Kline 2: price rises slightly, no condition trigger
klines.append({
"open": "98.5",
"high": "100",
"low": "98",
"close": "99",
"volume": "1000",
"amount": "99000",
"time": base_time + 7200
})
# Kline 3: price drops below stop loss
# Entry was 98.5, stop loss is 5%, so SL = 98.5 * 0.95 = 93.575
# This close (92) is below SL
klines.append({
"open": "99",
"high": "100",
"low": "90", # Well below stop loss
"close": "92", # Below stop loss (93.575)
"volume": "1000",
"amount": "92000",
"time": base_time + 10800
})
config = {**base_config, "strategy_config": stop_loss_strategy}
engine = BacktestEngine(config)
engine.ave_client = MockAveClient(klines)
results = asyncio.run(engine.run())
print(f"Results: {results}")
print(f"Trades: {engine.trades}")
print(f"DEBUG: stop_loss_percent = {engine.stop_loss_percent}")
print(f"DEBUG: take_profit_percent = {engine.take_profit_percent}")
# Should have 2 trades: 1 buy, 1 sell (stop loss)
assert results.get("total_trades") == 2
sell_trades = [t for t in engine.trades if t["type"] == "sell"]
assert len(sell_trades) == 1
assert sell_trades[0]["exit_reason"] == "stop_loss"
# Should have lost money (stop loss triggered)
assert results.get("total_return") < 0
assert results.get("final_balance") < 10000.0
sell_trades = [t for t in engine.trades if t["type"] == "sell"]
assert len(sell_trades) == 1
assert sell_trades[0]["exit_reason"] == "stop_loss"
# Should have lost money (stop loss triggered)
assert results.get("total_return") < 0
assert results.get("final_balance") < 10000.0
def test_take_profit_trigger(self, base_config, stop_loss_strategy):
"""Test take profit triggers correctly."""
# Create: buy, then price rises to trigger take profit
klines = []
base_time = 1704067200
# Kline 0: reference price (skipped due to idx=0)
klines.append({
"open": "100",
"high": "101",
"low": "99",
"close": "100",
"volume": "1000",
"amount": "100000",
"time": base_time
})
# Kline 1: drop triggers buy at 98.5 (1.5% drop from 100)
klines.append({
"open": "100",
"high": "101",
"low": "98",
"close": "98.5",
"volume": "1000",
"amount": "98500",
"time": base_time + 3600
})
# Kline 2: price stays roughly flat
klines.append({
"open": "98.5",
"high": "99",
"low": "98",
"close": "98.8",
"volume": "1000",
"amount": "98800",
"time": base_time + 7200
})
# Kline 3: price rises above take profit
# Entry was 98.5, take profit is 10%, so TP = 98.5 * 1.10 = 108.35
klines.append({
"open": "98.8",
"high": "120", # Way above TP
"low": "98",
"close": "115", # Above 108.35
"volume": "1000",
"amount": "115000",
"time": base_time + 10800
})
config = {**base_config, "strategy_config": stop_loss_strategy}
engine = BacktestEngine(config)
engine.ave_client = MockAveClient(klines)
results = asyncio.run(engine.run())
print(f"Results: {results}")
print(f"Trades: {engine.trades}")
print(f"DEBUG: stop_loss_percent = {engine.stop_loss_percent}")
print(f"DEBUG: take_profit_percent = {engine.take_profit_percent}")
# Should have 2 trades: 1 buy, 1 sell (take profit)
assert results.get("total_trades") == 2
sell_trades = [t for t in engine.trades if t["type"] == "sell"]
assert len(sell_trades) == 1
assert sell_trades[0]["exit_reason"] == "take_profit"
# Should have made profit (take profit triggered)
assert results.get("total_return") > 0
assert results.get("final_balance") > 10000.0
def test_full_cycle_dip_buy_sell(self, base_config):
"""Test a complete cycle: buy on dip, then sell via take profit."""
# Strategy: buy 10% on 1% dip, take profit 2% to sell
strategy = {
"conditions": [
{"type": "price_drop", "token": "TEST", "token_address": "0xtest", "threshold": 1.0}
],
"actions": [
{"type": "buy", "amount_percent": 10}
],
"risk_management": {
"take_profit_percent": 2.0 # Sell when price rises 2%
}
}
klines = []
base_time = 1704067200
# Klines:
# 0: price 100 (reference)
# 1: price 98.5 - 1.5% drop, triggers buy at 98.5
# 2: price 100.5 - 2% rise from 98.5 = 100.47, triggers take profit
klines.append({
"open": "100", "high": "101", "low": "99", "close": "100",
"volume": "1000", "amount": "100000", "time": base_time
})
klines.append({
"open": "100", "high": "101", "low": "97.5", "close": "98.5",
"volume": "1000", "amount": "98500", "time": base_time + 3600
})
klines.append({
"open": "98.5", "high": "101", "low": "98", "close": "100.5",
"volume": "1000", "amount": "100500", "time": base_time + 7200
})
config = {**base_config, "strategy_config": strategy}
engine = BacktestEngine(config)
engine.ave_client = MockAveClient(klines)
results = asyncio.run(engine.run())
print(f"Results: {results}")
print(f"Trades: {engine.trades}")
# Should have 2 trades: 1 buy, 1 sell (take profit)
assert results.get("total_trades") == 2
buy_trades = [t for t in engine.trades if t["type"] == "buy"]
sell_trades = [t for t in engine.trades if t["type"] == "sell"]
assert len(buy_trades) == 1
assert len(sell_trades) == 1
assert sell_trades[0]["exit_reason"] == "take_profit"
# Should have made profit
assert results.get("total_return") > 0
def test_multiple_buys_then_multiple_sells(self, base_config):
"""Test multiple buys followed by multiple sells via take profit."""
# Create strategy: buy 10% on 2% dip, take profit 2% to sell
strategy = {
"conditions": [
{"type": "price_drop", "token": "TEST", "token_address": "0xtest", "threshold": 2.0}
],
"actions": [
{"type": "buy", "amount_percent": 10}
],
"risk_management": {
"take_profit_percent": 2.5 # Sell when price rises 2.5% (slightly above entry drop)
}
}
# Price pattern: drop 2.5% (buy), rise 2.5% (take profit sells), repeat 3 times
klines = []
base_time = 1704067200
price = 100.0
for i in range(12):
if i % 2 == 0:
price = price * 0.975 # Drop 2.5% - triggers buy
else:
price = price * 1.026 # Rise 2.5% - triggers take profit sell
klines.append({
"open": str(price * 0.99),
"high": str(price * 1.01),
"low": str(price * 0.98),
"close": str(price),
"volume": "1000",
"amount": str(1000 * price),
"time": base_time + (i * 3600)
})
config = {**base_config, "strategy_config": strategy}
engine = BacktestEngine(config)
engine.ave_client = MockAveClient(klines)
results = asyncio.run(engine.run())
print(f"Results: {results}")
print(f"Trades: {engine.trades}")
# Price oscillates creating multiple dip/sell cycles
# Should have 10 trades: 5 buys, 5 sells (via take profit)
assert results.get("total_trades") == 10
buy_trades = [t for t in engine.trades if t["type"] == "buy"]
sell_trades = [t for t in engine.trades if t["type"] == "sell"]
assert len(buy_trades) == 5
assert len(sell_trades) == 5
# Position should be 0 after all sells
assert engine.position == 0
# Should have profitable trades
assert results.get("total_return") > 0
if __name__ == "__main__":
pytest.main([__file__, "-v"])

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

@@ -7,6 +7,9 @@
"": { "": {
"name": "frontend", "name": "frontend",
"version": "0.0.1", "version": "0.0.1",
"dependencies": {
"chart.js": "^4.5.1"
},
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^7.0.1", "@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/kit": "^2.57.0", "@sveltejs/kit": "^2.57.0",
@@ -101,6 +104,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz",
@@ -569,6 +578,18 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chokidar": { "node_modules/chokidar": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",

View File

@@ -19,5 +19,8 @@
"svelte-check": "^4.4.6", "svelte-check": "^4.4.6",
"typescript": "^6.0.2", "typescript": "^6.0.2",
"vite": "^8.0.7" "vite": "^8.0.7"
},
"dependencies": {
"chart.js": "^4.5.1"
} }
} }

View File

@@ -8,7 +8,10 @@ import type {
AuthResponse, AuthResponse,
BotChatRequest, BotChatRequest,
BotChatResponse, BotChatResponse,
StrategyConfig StrategyConfig,
Conversation,
ConversationWithMessages,
Message
} from './types'; } from './types';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000/api'; const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000/api';
@@ -18,10 +21,33 @@ function getAuthHeaders(): HeadersInit {
return token ? { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } : { 'Content-Type': 'application/json' }; return token ? { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } : { 'Content-Type': 'application/json' };
} }
class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'ApiError';
this.status = status;
}
}
async function handleResponse<T>(response: Response): Promise<T> { async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) { if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'An error occurred' })); const error = await response.json().catch(() => ({ detail: 'An error occurred' }));
throw new Error(error.detail || `HTTP error ${response.status}`); 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 ApiError(errorMessage, response.status);
} }
return response.json(); return response.json();
} }
@@ -41,7 +67,7 @@ export const api = {
const response = await fetch(`${API_URL}/auth/login`, { const response = await fetch(`${API_URL}/auth/login`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }) body: JSON.stringify({ username: email, password })
}); });
return handleResponse<AuthResponse>(response); return handleResponse<AuthResponse>(response);
}, },
@@ -104,11 +130,12 @@ export const api = {
} }
}, },
async chat(id: string, message: string): Promise<BotChatResponse> { async chat(id: string, message: string, signal?: AbortSignal): Promise<BotChatResponse> {
const response = await fetch(`${API_URL}/bots/${id}/chat`, { const response = await fetch(`${API_URL}/bots/${id}/chat`, {
method: 'POST', method: 'POST',
headers: getAuthHeaders(), headers: getAuthHeaders(),
body: JSON.stringify({ message } as BotChatRequest) body: JSON.stringify({ message } as BotChatRequest),
signal
}); });
return handleResponse<BotChatResponse>(response); return handleResponse<BotChatResponse>(response);
}, },
@@ -126,7 +153,7 @@ export const api = {
const response = await fetch(`${API_URL}/bots/${botId}/backtest`, { const response = await fetch(`${API_URL}/bots/${botId}/backtest`, {
method: 'POST', method: 'POST',
headers: getAuthHeaders(), headers: getAuthHeaders(),
body: JSON.stringify(config) body: JSON.stringify({ ...config, chain: 'bsc' })
}); });
return handleResponse<Backtest>(response); return handleResponse<Backtest>(response);
}, },
@@ -153,11 +180,29 @@ export const api = {
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP error ${response.status}`); 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: { simulate: {
async start(botId: string, config: { token: string; interval_seconds: number; auto_execute: boolean }): Promise<Simulation> { async start(botId: string, config: { token: string; chain?: string; kline_interval: string }): Promise<Simulation> {
const response = await fetch(`${API_URL}/bots/${botId}/simulate`, { const response = await fetch(`${API_URL}/bots/${botId}/simulate`, {
method: 'POST', method: 'POST',
headers: getAuthHeaders(), headers: getAuthHeaders(),
@@ -205,5 +250,58 @@ export const api = {
}); });
return handleResponse<{ symbol: string; chain: string; name: string }[]>(response); return handleResponse<{ symbol: string; chain: string; name: string }[]>(response);
} }
},
conversations: {
async list(): Promise<Conversation[]> {
const response = await fetch(`${API_URL}/conversations`, {
headers: getAuthHeaders()
});
return handleResponse<Conversation[]>(response);
},
async create(): Promise<Conversation> {
const response = await fetch(`${API_URL}/conversations`, {
method: 'POST',
headers: getAuthHeaders()
});
return handleResponse<Conversation>(response);
},
async get(id: string): Promise<ConversationWithMessages> {
const response = await fetch(`${API_URL}/conversations/${id}`, {
headers: getAuthHeaders()
});
return handleResponse<ConversationWithMessages>(response);
},
async delete(id: string): Promise<void> {
const response = await fetch(`${API_URL}/conversations/${id}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
if (!response.ok) {
throw new Error(`HTTP error ${response.status}`);
}
},
async chat(id: string, message: string, signal?: AbortSignal): Promise<ConversationWithMessages> {
const response = await fetch(`${API_URL}/conversations/${id}/chat`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ message }),
signal
});
return handleResponse<ConversationWithMessages>(response);
},
async setBot(id: string, botId: string): Promise<Conversation> {
const response = await fetch(`${API_URL}/conversations/${id}/set-bot`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ bot_id: botId })
});
return handleResponse<Conversation>(response);
}
} }
}; };

View File

@@ -26,6 +26,7 @@ export interface StrategyConfig {
export interface Condition { export interface Condition {
type: 'price_drop' | 'price_rise' | 'volume_spike' | 'price_level'; type: 'price_drop' | 'price_rise' | 'volume_spike' | 'price_level';
token: string; token: string;
token_address?: string;
chain?: string; chain?: string;
threshold?: number; threshold?: number;
price?: number; price?: number;
@@ -37,6 +38,7 @@ export interface Action {
type: 'buy' | 'sell' | 'hold'; type: 'buy' | 'sell' | 'hold';
amount_percent?: number; amount_percent?: number;
token?: string; token?: string;
token_address?: string;
} }
export interface RiskManagement { export interface RiskManagement {
@@ -62,13 +64,16 @@ export interface Backtest {
bot_id: string; bot_id: string;
started_at: string; started_at: string;
ended_at: string | null; ended_at: string | null;
status: 'running' | 'completed' | 'failed'; status: 'running' | 'completed' | 'failed' | 'stopped';
config: BacktestConfig; config: BacktestConfig;
result: BacktestResult | null; result: BacktestResult | null;
progress?: number;
} }
export interface BacktestConfig { export interface BacktestConfig {
token: string; token: string;
token_name?: string;
chain: string;
timeframe: string; timeframe: string;
start_date: string; start_date: string;
end_date: string; end_date: string;
@@ -84,19 +89,63 @@ export interface BacktestResult {
sharpe_ratio: 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 { export interface Simulation {
id: string; id: string;
bot_id: string; bot_id: string;
started_at: string; started_at: string;
status: 'running' | 'stopped'; status: 'running' | 'stopped' | 'completed';
config: SimulationConfig; config: SimulationConfig;
signals: Signal[] | null; 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 { export interface SimulationConfig {
token: string; token: string;
interval_seconds: number; chain?: string;
auto_execute: boolean; 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 { export interface Signal {
@@ -123,6 +172,38 @@ export interface BotChatRequest {
export interface BotChatResponse { export interface BotChatResponse {
response: string; response: string;
thinking: string | null;
strategy_config: StrategyConfig | null; strategy_config: StrategyConfig | null;
success: boolean; 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;
}
export interface Conversation {
id: string;
user_id: string | null;
bot_id: string | null;
title: string;
created_at: string;
updated_at: string;
}
export interface Message {
id: string;
conversation_id: string;
role: 'user' | 'assistant';
content: string;
created_at: string;
}
export interface ConversationWithMessages extends Conversation {
messages: Message[];
} }

View File

@@ -0,0 +1,68 @@
<script lang="ts">
interface Props {
chatCount?: number;
}
let { chatCount = 0 }: Props = $props();
const showWarning = chatCount >= 40;
const limit = 50;
</script>
<div class="anonymous-banner" class:warning={showWarning}>
<div class="banner-content">
{#if showWarning}
<span class="icon">⚠️</span>
<span>
Warning: You've used {chatCount}/{limit} messages.
<a href="/login" class="link">Login to continue</a>
</span>
{:else}
<span class="icon">💬</span>
<span>
Your progress is not saved.
<a href="/login" class="link">Login to save</a>
</span>
{/if}
</div>
</div>
<style>
.anonymous-banner {
padding: 0.5rem 1rem;
background: rgba(251, 191, 36, 0.1);
border-bottom: 1px solid rgba(251, 191, 36, 0.2);
}
.anonymous-banner.warning {
background: rgba(220, 38, 38, 0.15);
border-bottom-color: rgba(220, 38, 38, 0.2);
}
.banner-content {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
font-size: 0.85rem;
color: #fbbf24;
}
.warning .banner-content {
color: #fca5a5;
}
.icon {
font-size: 1rem;
}
.link {
color: inherit;
text-decoration: underline;
font-weight: 500;
}
.link:hover {
opacity: 0.8;
}
</style>

View File

@@ -0,0 +1,115 @@
<script lang="ts">
import { isAuthenticated, logout, userStore } from '$lib/stores';
import { goto } from '$app/navigation';
function handleLogout() {
logout();
goto('/');
}
</script>
<header class="app-header">
<div class="header-left">
<a href="/home" class="logo">
<span class="logo-text">Randebu</span>
</a>
{#if $isAuthenticated}
<a href="/dashboard" class="nav-link">Dashboard</a>
{/if}
</div>
<div class="header-right">
{#if $isAuthenticated}
<span class="user-info">{$userStore?.username || 'User'}</span>
<button class="btn btn-ghost" onclick={handleLogout}>
Logout
</button>
{:else}
<a href="/login" class="btn btn-ghost">Login</a>
<a href="/register" class="btn btn-primary">Register</a>
{/if}
</div>
</header>
<style>
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1.25rem;
height: 56px;
background: rgba(0, 0, 0, 0.5);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
flex-shrink: 0;
}
.header-left {
display: flex;
align-items: center;
gap: 1.5rem;
}
.logo {
text-decoration: none;
}
.logo-text {
font-size: 1.25rem;
font-weight: 700;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.nav-link {
color: #888;
text-decoration: none;
font-size: 0.9rem;
transition: color 0.2s;
}
.nav-link:hover {
color: #fff;
}
.header-right {
display: flex;
align-items: center;
gap: 1rem;
}
.user-info {
color: #888;
font-size: 0.9rem;
}
.btn {
padding: 0.5rem 1rem;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
text-decoration: none;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.btn-ghost {
background: transparent;
color: #888;
}
.btn-ghost:hover {
color: #fff;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
}
</style>

View File

@@ -3,24 +3,21 @@
interface Props { interface Props {
bot: Bot; bot: Bot;
onOpen?: (botId: string) => void;
onDelete?: (botId: string) => void; onDelete?: (botId: string) => void;
showActions?: boolean; showActions?: boolean;
} }
let { bot, onOpen, onDelete, showActions = true }: Props = $props(); let { bot, onDelete, showActions = true }: Props = $props();
function handleOpen() {
onOpen?.(bot.id);
}
function handleDelete(e: Event) { function handleDelete(e: Event) {
e.preventDefault();
e.stopPropagation(); e.stopPropagation();
onDelete?.(bot.id); onDelete?.(bot.id);
} }
</script> </script>
<div class="bot-card" onclick={handleOpen} role="button" tabindex="0" onkeydown={(e) => e.key === 'Enter' && handleOpen()}> <div class="bot-card">
<a href="/chat/{bot.id}" class="bot-card-link" data-sveltekit-preload-data="hover" aria-label="Open {bot.name}"></a>
<div class="bot-info"> <div class="bot-info">
<h3>{bot.name}</h3> <h3>{bot.name}</h3>
{#if bot.description} {#if bot.description}
@@ -29,8 +26,8 @@
<span class="bot-status status-{bot.status}">{bot.status}</span> <span class="bot-status status-{bot.status}">{bot.status}</span>
</div> </div>
{#if showActions} {#if showActions}
<div class="bot-actions" onclick={(e) => e.stopPropagation()} role="group"> <div class="bot-actions" role="group">
<button class="btn btn-primary" onclick={handleOpen}>Open</button> <a href="/chat/{bot.id}" class="btn btn-primary" data-sveltekit-preload-data="hover">Open</a>
<button class="btn btn-danger" onclick={handleDelete}>Delete</button> <button class="btn btn-danger" onclick={handleDelete}>Delete</button>
</div> </div>
{/if} {/if}
@@ -38,11 +35,11 @@
<style> <style>
.bot-card { .bot-card {
position: relative;
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px; border-radius: 12px;
padding: 1.5rem; padding: 1.5rem;
cursor: pointer;
transition: transform 0.2s, border-color 0.2s; transition: transform 0.2s, border-color 0.2s;
} }
@@ -51,13 +48,17 @@
border-color: rgba(255, 255, 255, 0.2); border-color: rgba(255, 255, 255, 0.2);
} }
.bot-card:focus { .bot-card-link {
outline: 2px solid #667eea; position: absolute;
outline-offset: 2px; inset: 0;
border-radius: 12px;
z-index: 0;
} }
.bot-info { .bot-info {
margin-bottom: 1rem; margin-bottom: 1rem;
position: relative;
z-index: 1;
} }
.bot-info h3 { .bot-info h3 {
@@ -98,6 +99,8 @@
.bot-actions { .bot-actions {
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;
position: relative;
z-index: 2;
} }
.btn { .btn {
@@ -108,6 +111,7 @@
cursor: pointer; cursor: pointer;
border: none; border: none;
transition: transform 0.2s, opacity 0.2s; transition: transform 0.2s, opacity 0.2s;
text-decoration: none;
} }
.btn-primary { .btn-primary {

View File

@@ -0,0 +1,434 @@
<script lang="ts">
import type { Bot, Condition, Action, RiskManagement } from '$lib/api';
interface Props {
bot?: Bot | null;
onSelectBot?: () => void;
onBacktest?: () => void;
onSimulate?: () => void;
}
let { bot = null, onSelectBot, onBacktest, onSimulate }: Props = $props();
// Helper to get human-readable condition name
function getConditionLabel(condition: Condition): string {
const labels: Record<string, string> = {
'price_drop': 'Price Drop',
'price_rise': 'Price Rise',
'volume_spike': 'Volume Spike',
'price_level': 'Price Level'
};
return labels[condition.type] || condition.type;
}
// Format condition to readable string
function formatCondition(condition: Condition): string {
const parts: string[] = [getConditionLabel(condition)];
if (condition.token) {
parts.push(condition.token.toUpperCase());
}
if (condition.direction) {
parts.push(condition.direction);
}
if (condition.threshold) {
parts.push(`>${condition.threshold}%`);
}
if (condition.price) {
parts.push(`$${condition.price}`);
}
if (condition.timeframe) {
parts.push(`(${condition.timeframe})`);
}
return parts.join(' ');
}
// Format action to readable string
function formatAction(action: Action): string {
const parts: string[] = [];
if (action.type === 'buy') {
parts.push('Buy');
if (action.amount_percent) {
parts.push(`${action.amount_percent}%`);
}
if (action.token) {
parts.push(`of ${action.token.toUpperCase()}`);
}
} else if (action.type === 'sell') {
parts.push('Sell');
if (action.amount_percent) {
parts.push(`${action.amount_percent}%`);
}
} else {
parts.push('Hold');
}
return parts.join(' ');
}
let hasStrategy = $derived(
bot?.strategy_config &&
((bot.strategy_config.conditions && bot.strategy_config.conditions.length > 0) ||
(bot.strategy_config.actions && bot.strategy_config.actions.length > 0) ||
bot.strategy_config.risk_management)
);
</script>
<div class="bot-info-panel">
<h3>Bot Details</h3>
{#if !bot}
<div class="no-bot">
<p>No bot selected</p>
{#if onSelectBot}
<button class="btn btn-secondary" onclick={onSelectBot}>
Select Bot
</button>
{/if}
</div>
{:else}
<div class="bot-details">
<div class="bot-name">{bot.name}</div>
<div class="detail-row">
<span class="label">Status:</span>
<span class="value" class:active={bot.status === 'active'}>
{bot.status}
</span>
</div>
</div>
<div class="strategy-section">
<h4>Strategy</h4>
{#if hasStrategy}
{#if bot.strategy_config?.conditions && bot.strategy_config.conditions.length > 0}
<div class="strategy-group">
<div class="strategy-group-label">When:</div>
{#each bot.strategy_config.conditions as condition}
<div class="condition-item">
<span class="condition-icon">📊</span>
{formatCondition(condition)}
</div>
{/each}
</div>
{/if}
{#if bot.strategy_config?.actions && bot.strategy_config.actions.length > 0}
<div class="strategy-group">
<div class="strategy-group-label">Then:</div>
{#each bot.strategy_config.actions as action}
<div class="action-item">
<span class="action-icon">{action.type === 'buy' ? '🟢' : action.type === 'sell' ? '🔴' : '⏸️'}</span>
{formatAction(action)}
</div>
{/each}
</div>
{/if}
{#if bot.strategy_config?.risk_management}
<div class="strategy-group">
<div class="strategy-group-label">Risk:</div>
<div class="risk-item">
{#if bot.strategy_config.risk_management.stop_loss_percent}
<span class="risk-tag loss">Stop Loss: {bot.strategy_config.risk_management.stop_loss_percent}%</span>
{/if}
{#if bot.strategy_config.risk_management.take_profit_percent}
<span class="risk-tag profit">Take Profit: {bot.strategy_config.risk_management.take_profit_percent}%</span>
{/if}
</div>
</div>
{/if}
{:else}
<div class="no-strategy">
<div class="no-strategy-icon">⚙️</div>
<p>No strategy configured</p>
<span class="no-strategy-hint">Chat with the bot to set up your trading strategy</span>
</div>
{/if}
</div>
{#if onSelectBot}
<button class="btn btn-outline" onclick={onSelectBot}>
Change Bot
</button>
{/if}
<div class="action-buttons">
<h4>Tools</h4>
<div class="button-row">
<button
class="btn btn-tool btn-backtest"
onclick={onBacktest}
disabled={!bot || !hasStrategy}
>
📊 Backtest
</button>
<button
class="btn btn-tool btn-simulate"
onclick={onSimulate}
disabled={!bot || !hasStrategy}
>
🎮 Simulate
</button>
</div>
{#if !hasStrategy}
<p class="tool-hint">Configure a strategy first to use these tools</p>
{/if}
</div>
{/if}
</div>
<style>
.bot-info-panel {
padding: 1.25rem;
height: 100%;
overflow-y: auto;
}
h3 {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #888;
margin: 0 0 1rem;
font-weight: 600;
}
h4 {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #666;
margin: 1rem 0 0.75rem;
font-weight: 600;
}
.no-bot {
text-align: center;
padding: 1rem 0;
}
.no-bot p {
color: #666;
margin: 0 0 1rem;
}
.bot-details {
margin-bottom: 0.5rem;
}
.bot-name {
font-size: 1.25rem;
font-weight: 600;
color: #fff;
margin-bottom: 1rem;
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 0.5rem 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.label {
color: #888;
font-size: 0.9rem;
}
.value {
color: #ccc;
font-size: 0.9rem;
}
.value.active {
color: #3fb950;
}
.strategy-section {
background: rgba(255, 255, 255, 0.03);
border-radius: 8px;
padding: 1rem;
margin-top: 0.5rem;
}
.strategy-group {
margin-bottom: 0.75rem;
}
.strategy-group:last-child {
margin-bottom: 0;
}
.strategy-group-label {
font-size: 0.75rem;
color: #667eea;
font-weight: 600;
text-transform: uppercase;
margin-bottom: 0.25rem;
}
.condition-item,
.action-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0;
font-size: 0.85rem;
color: #ccc;
}
.condition-icon,
.action-icon {
font-size: 0.9rem;
}
.risk-item {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.risk-tag {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-weight: 500;
}
.risk-tag.loss {
background: rgba(248, 81, 73, 0.2);
color: #f85149;
}
.risk-tag.profit {
background: rgba(63, 185, 80, 0.2);
color: #3fb950;
}
.no-strategy {
text-align: center;
padding: 1rem 0.5rem;
}
.no-strategy-icon {
font-size: 2rem;
margin-bottom: 0.5rem;
opacity: 0.5;
}
.no-strategy p {
color: #888;
margin: 0 0 0.25rem;
font-size: 0.9rem;
}
.no-strategy-hint {
font-size: 0.75rem;
color: #666;
}
.action-buttons {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.action-buttons h4 {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #666;
margin: 0 0 0.75rem;
font-weight: 600;
}
.button-row {
display: flex;
gap: 0.5rem;
}
.btn-tool {
flex: 1;
padding: 0.6rem 0.5rem;
border-radius: 6px;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
}
.btn-tool:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-backtest {
background: rgba(102, 126, 234, 0.15);
color: #667eea;
border: 1px solid rgba(102, 126, 234, 0.3);
}
.btn-backtest:hover:not(:disabled) {
background: rgba(102, 126, 234, 0.25);
}
.btn-simulate {
background: rgba(63, 185, 80, 0.15);
color: #3fb950;
border: 1px solid rgba(63, 185, 80, 0.3);
}
.btn-simulate:hover:not(:disabled) {
background: rgba(63, 185, 80, 0.25);
}
.tool-hint {
font-size: 0.7rem;
color: #555;
margin: 0.5rem 0 0;
text-align: center;
}
.btn {
width: 100%;
padding: 0.75rem 1rem;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
margin-top: 1rem;
}
.btn-secondary {
background: rgba(255, 255, 255, 0.1);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.15);
}
.btn-outline {
background: transparent;
color: #667eea;
border: 1px solid #667eea;
}
.btn-outline:hover {
background: rgba(102, 126, 234, 0.1);
}
</style>

View File

@@ -0,0 +1,47 @@
<script lang="ts">
let { class: className = '' } = $props();
const bars = [0, 1, 2, 3];
const heights = ['12px', '18px', '14px', '20px'];
const delays = ['0s', '0.15s', '0.3s', '0.45s'];
</script>
<div class="flex items-center gap-1 h-8 {className}">
{#each bars as i}
<div
class="w-2 bg-green-500 rounded-sm animate-pulse"
style="height: {heights[i]}; animation-delay: {delays[i]};"
></div>
{/each}
</div>
<style>
.flex {
display: flex;
}
.items-center {
align-items: center;
}
.gap-1 {
gap: 0.25rem;
}
.h-8 {
height: 2rem;
}
.w-2 {
width: 0.5rem;
}
.bg-green-500 {
background-color: rgb(34 197 94);
}
.rounded-sm {
border-radius: 0.125rem;
}
.animate-pulse {
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.4; transform: scaleY(1); }
50% { opacity: 1; transform: scaleY(1.2); }
}
</style>

View File

@@ -0,0 +1,188 @@
<script lang="ts">
import type { Message } from '$lib/api';
import { parseMarkdown, type ParsedSegment } from '$lib/utils/markdown';
import CandlestickLoader from './CandlestickLoader.svelte';
interface Props {
conversationId?: string;
messages?: Message[];
isLoading?: boolean;
}
let { conversationId, messages = [], isLoading = false }: Props = $props();
let chatContainer: HTMLDivElement;
function renderContent(content: string): ParsedSegment[] {
return parseMarkdown(content);
}
$effect(() => {
if (messages.length && chatContainer) {
setTimeout(() => {
chatContainer.scrollTop = chatContainer.scrollHeight;
}, 50);
}
});
</script>
<div class="chat-area" bind:this={chatContainer}>
{#if !conversationId}
<div class="empty-state">
Select a conversation or start a new one
</div>
{:else if messages.length === 0 && !isLoading}
<div class="empty-state">
Send a message to start the conversation
</div>
{:else}
<div class="messages">
{#each messages as msg (msg.id)}
<div class="message" class:user={msg.role === 'user'} class:assistant={msg.role === 'assistant'}>
<div class="message-content">
{#each renderContent(msg.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>{item}</li>
{/each}
</ul>
{:else if segment.type === 'lineBreak'}
<br />
{:else}
{segment.content}
{/if}
{/each}
</div>
<div class="message-time">
{new Date(msg.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</div>
</div>
{/each}
</div>
{/if}
{#if isLoading}
<div class="message assistant">
<div class="message-content">
<CandlestickLoader />
</div>
</div>
{/if}
</div>
<style>
.chat-area {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
padding-bottom: 0.5rem;
}
.empty-state {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #666;
font-size: 1rem;
}
.messages {
display: flex;
flex-direction: column;
gap: 1rem;
}
.message {
display: flex;
flex-direction: column;
max-width: 75%;
}
.message.user {
align-self: flex-end;
align-items: flex-end;
}
.message.assistant {
align-self: flex-start;
align-items: flex-start;
}
.message-content {
padding: 0.875rem 1rem;
border-radius: 12px;
line-height: 1.6;
font-size: 0.95rem;
}
.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.08);
color: #e0e0e0;
border-bottom-left-radius: 4px;
}
.message-time {
font-size: 0.7rem;
color: #555;
margin-top: 0.25rem;
padding: 0 0.5rem;
}
.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: 8px;
overflow-x: auto;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.85rem;
margin: 0.5rem 0;
}
pre.code-block code {
white-space: pre;
}
ul {
margin: 0.5rem 0;
padding-left: 1.25rem;
}
li {
margin: 0.25rem 0;
}
a {
color: #667eea;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
</style>

View File

@@ -0,0 +1,113 @@
<script lang="ts">
interface Props {
onSend: (message: string) => void;
disabled?: boolean;
placeholder?: string;
}
let { onSend, disabled = false, placeholder = "Type a message..." }: Props = $props();
let messageInput = $state('');
let textarea: HTMLTextAreaElement;
function handleSend() {
if (!messageInput.trim() || disabled) return;
onSend(messageInput);
messageInput = '';
if (textarea) {
textarea.style.height = 'auto';
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}
function handleInput() {
if (textarea) {
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 150) + 'px';
}
}
</script>
<div class="chat-input">
<textarea
bind:this={textarea}
bind:value={messageInput}
onkeydown={handleKeydown}
oninput={handleInput}
{placeholder}
{disabled}
rows="1"
></textarea>
<button onclick={handleSend} {disabled}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/>
</svg>
</button>
</div>
<style>
.chat-input {
display: flex;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.3);
flex-shrink: 0;
}
textarea {
flex: 1;
padding: 0.875rem 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;
font-family: inherit;
resize: none;
min-height: 48px;
max-height: 150px;
}
textarea:focus {
outline: none;
border-color: #667eea;
}
textarea::placeholder {
color: #666;
}
textarea:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button {
padding: 0.75rem 1.25rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 12px;
cursor: pointer;
transition: transform 0.2s, opacity 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
button:hover:not(:disabled) {
transform: translateY(-2px);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

View File

@@ -1,13 +1,55 @@
<script lang="ts"> <script lang="ts">
import type { Bot } from '$lib/api'; import type { Bot } from '$lib/api';
import type { ChatMessage } from '$lib/stores/chatStore'; import type { ChatMessage } from '$lib/stores/chatStore';
import { parseMarkdown, parseInlineElements, type InlineSegment } from '$lib/utils/markdown';
interface ToolGroup {
category: string;
label: string;
requiresBot: boolean;
tools: ToolItem[];
}
interface ToolItem {
name: string;
description: string;
command: string;
}
const TOOLS: ToolGroup[] = [
{
category: 'randebu',
label: '🤖 Randebu Built-in',
requiresBot: true,
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',
requiresBot: false,
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 { interface Props {
bot: Bot | null; bot: Bot | null;
messages: ChatMessage[]; messages: ChatMessage[];
isSending?: boolean; isSending?: boolean;
isBlocked?: boolean;
blockedReason?: string | null;
onSendMessage: (message: string) => void; onSendMessage: (message: string) => void;
onSelectBot?: (botId: string) => void; onSelectBot?: (botId: string) => void;
onLogin?: () => void;
availableBots?: Bot[]; availableBots?: Bot[];
showBotSelector?: boolean; showBotSelector?: boolean;
} }
@@ -16,17 +58,45 @@
bot, bot,
messages, messages,
isSending = false, isSending = false,
isBlocked = false,
blockedReason = null,
onSendMessage, onSendMessage,
onSelectBot, onSelectBot,
onLogin,
availableBots = [], availableBots = [],
showBotSelector = false showBotSelector = false
}: Props = $props(); }: Props = $props();
let messageInput = $state(''); let messageInput = $state('');
let chatContainer: HTMLDivElement; 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
// Filter tools based on whether user has a bot
let availableTools = $derived(
TOOLS.flatMap(t => !t.requiresBot || bot ? t.tools : [])
);
let filteredTools = $derived(
messageInput.startsWith('/')
? availableTools.filter(tool =>
tool.name.toLowerCase().startsWith(messageInput.slice(1).toLowerCase()) ||
tool.command.toLowerCase().startsWith(messageInput.slice(1).toLowerCase())
)
: []
);
// Get visible tool groups for the menu
let visibleGroups = $derived(
TOOLS.filter(group => !group.requiresBot || bot)
);
function handleSend() { function handleSend() {
if (!messageInput.trim()) return; if (!messageInput.trim()) return;
showSlashMenu = false;
onSendMessage(messageInput); onSendMessage(messageInput);
messageInput = ''; messageInput = '';
} }
@@ -34,8 +104,55 @@
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
if (showSlashMenu && filteredTools.length > 0) {
selectTool(filteredTools[selectedIndex]);
} else {
handleSend(); 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) { function handleBotChange(e: Event) {
@@ -45,6 +162,10 @@
} }
} }
function toggleThinkingExpand(messageId: string) {
expandedThinking[messageId] = !expandedThinking[messageId];
}
$effect(() => { $effect(() => {
if (messages.length && chatContainer) { if (messages.length && chatContainer) {
setTimeout(() => { setTimeout(() => {
@@ -52,8 +173,33 @@
}, 50); }, 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> </script>
<svelte:window on:click={handleClickOutside} />
<div class="chat-interface"> <div class="chat-interface">
{#if showBotSelector && availableBots.length > 0} {#if showBotSelector && availableBots.length > 0}
<div class="bot-selector"> <div class="bot-selector">
@@ -78,8 +224,99 @@
{#each messages as message} {#each messages as message}
<div class="message {message.role}"> <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"> <div class="message-content">
{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>
<div class="message-time"> <div class="message-time">
{message.timestamp.toLocaleTimeString()} {message.timestamp.toLocaleTimeString()}
@@ -89,31 +326,85 @@
{#if isSending} {#if isSending}
<div class="message assistant"> <div class="message assistant">
<div class="message-content typing"> <div class="message-content">
<div class="typing">
<span class="dot"></span> <span class="dot"></span>
<span class="dot"></span> <span class="dot"></span>
<span class="dot"></span> <span class="dot"></span>
</div> </div>
</div> </div>
</div>
{/if} {/if}
</div> </div>
{#if bot}
<div class="input-container"> <div class="input-container">
{#if isBlocked}
<div class="blocked-message">
<div class="blocked-icon">🚫</div>
<div class="blocked-text">
{#if blockedReason === 'message_limit'}
<p><strong>Message Limit Reached</strong></p>
<p>You've used all 50 messages as an anonymous user.</p>
<p>Login to continue chatting with unlimited messages.</p>
{:else if blockedReason === 'bot_limit'}
<p><strong>Bot Limit Reached</strong></p>
<p>You've created the maximum of 1 bot as an anonymous user.</p>
<p>Login to create more bots.</p>
{:else if blockedReason === 'backtest_limit'}
<p><strong>Backtest Limit Reached</strong></p>
<p>You've run the maximum of 1 backtest as an anonymous user.</p>
<p>Login to run more backtests.</p>
{:else}
<p><strong>Action Blocked</strong></p>
<p>Please login to continue.</p>
{/if}
<button class="login-button" onclick={() => onLogin?.()}>
Login to Continue
</button>
</div>
</div>
{:else}
{#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 visibleGroups 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">
{#if !bot}
Login to access bot commands
{:else}
Press Tab to select, Enter to send
{/if}
</div>
</div>
{/if}
<textarea <textarea
bind:value={messageInput} value={messageInput}
oninput={handleInput}
onkeydown={handleKeydown} onkeydown={handleKeydown}
placeholder="Describe your trading strategy..." placeholder="Describe your trading strategy... (or type / for commands)"
rows="1" rows="1"
disabled={isSending} disabled={isSending}
></textarea> ></textarea>
<button onclick={handleSend} disabled={isSending || !messageInput.trim()}> <button onclick={handleSend} disabled={isSending || !messageInput.trim()}>
Send Send
</button> </button>
</div>
{/if} {/if}
</div>
</div> </div>
<style> <style>
.chat-interface { .chat-interface {
display: flex; display: flex;
@@ -206,6 +497,64 @@
border-bottom-left-radius: 4px; 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 { .message.system .message-content {
background: rgba(251, 191, 36, 0.1); background: rgba(251, 191, 36, 0.1);
color: #fbbf24; color: #fbbf24;
@@ -213,6 +562,92 @@
border: 1px solid rgba(251, 191, 36, 0.3); 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 { .message-time {
font-size: 0.7rem; font-size: 0.7rem;
color: #666; color: #666;
@@ -223,7 +658,7 @@
.typing { .typing {
display: flex; display: flex;
gap: 4px; gap: 4px;
padding: 1rem 1.25rem; padding: 0.5rem;
} }
.dot { .dot {
@@ -253,6 +688,56 @@
} }
} }
.blocked-message {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: rgba(251, 191, 36, 0.1);
border: 1px solid rgba(251, 191, 36, 0.3);
border-radius: 12px;
width: 100%;
}
.blocked-icon {
font-size: 2rem;
}
.blocked-text {
flex: 1;
}
.blocked-text p {
margin: 0.25rem 0;
font-size: 0.9rem;
color: #fbbf24;
}
.blocked-text p:first-child {
margin-top: 0;
}
.blocked-text p:last-child {
margin-bottom: 0;
}
.login-button {
padding: 0.75rem 1.5rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: transform 0.2s;
white-space: nowrap;
}
.login-button:hover {
transform: translateY(-2px);
}
.input-container { .input-container {
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;
@@ -297,4 +782,76 @@
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; 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> </style>

View File

@@ -0,0 +1,82 @@
<script lang="ts">
import type { Component } from 'svelte';
interface Props {
leftPane?: Component;
rightPane?: Component;
rightPaneProps?: Record<string, any>;
children?: any;
}
let { leftPane: LeftPane, rightPane: RightPane, rightPaneProps = {}, children }: Props = $props();
</script>
<div class="chat-layout">
{#if LeftPane}
<aside class="sidebar-left">
<LeftPane />
</aside>
{/if}
<main class="main-content">
{#if children}
{@render children()}
{/if}
</main>
{#if RightPane}
<aside class="sidebar-right">
<RightPane {...rightPaneProps} />
</aside>
{/if}
</div>
<style>
.chat-layout {
display: flex;
height: 100%;
width: 100%;
}
.sidebar-left {
width: 280px;
border-right: 1px solid rgba(255, 255, 255, 0.1);
flex-shrink: 0;
overflow: hidden;
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
background: #0f0f0f;
}
.sidebar-right {
width: 300px;
border-left: 1px solid rgba(255, 255, 255, 0.1);
flex-shrink: 0;
overflow-y: auto;
background: rgba(0, 0, 0, 0.2);
}
@media (max-width: 1024px) {
.sidebar-right {
display: none;
}
}
@media (max-width: 768px) {
.sidebar-left {
width: 100%;
position: absolute;
left: 0;
top: 0;
bottom: 0;
z-index: 100;
background: #0f0f0f;
}
}
</style>

View File

@@ -0,0 +1,178 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { api } from '$lib/api';
import { conversationStore, setConversations, addConversation } from '$lib/stores';
let conversations = $state<any[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
$effect(() => {
const unsub = conversationStore.subscribe(state => {
conversations = state.conversations;
});
return unsub;
});
onMount(async () => {
await loadConversations();
});
async function loadConversations() {
loading = true;
error = null;
try {
const data = await api.conversations.list();
setConversations(data);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load conversations';
setConversations([]);
} finally {
loading = false;
}
}
async function createConversation() {
try {
const newConv = await api.conversations.create();
addConversation(newConv);
goto(`/chat/${newConv.id}`);
} catch (e) {
console.error('Failed to create conversation:', e);
}
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
function isActiveConversation(convId: string): boolean {
return $page.params.conversationId === convId;
}
</script>
<div class="conversation-list">
<div class="header">
<button class="new-chat-btn" onclick={createConversation}>
+ New Chat
</button>
</div>
<div class="list">
{#if loading}
<div class="loading">Loading...</div>
{:else if error}
<div class="error">{error}</div>
{:else if conversations.length === 0}
<div class="empty">No conversations yet</div>
{:else}
{#each conversations as conv (conv.id)}
<button
class="conversation-item"
class:active={isActiveConversation(conv.id)}
onclick={() => goto(`/chat/${conv.id}`)}
>
<div class="conv-title">{conv.title || 'New Chat'}</div>
<div class="conv-date">{formatDate(conv.updated_at)}</div>
</button>
{/each}
{/if}
</div>
</div>
<style>
.conversation-list {
height: 100%;
display: flex;
flex-direction: column;
background: #0f0f0f;
}
.header {
padding: 0.75rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.new-chat-btn {
width: 100%;
padding: 0.75rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: transform 0.2s;
}
.new-chat-btn:hover {
transform: translateY(-2px);
}
.list {
flex: 1;
overflow-y: auto;
}
.loading,
.error,
.empty {
padding: 1.5rem;
text-align: center;
color: #666;
font-size: 0.9rem;
}
.error {
color: #f87171;
}
.conversation-item {
width: 100%;
padding: 0.875rem 1rem;
background: transparent;
border: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
text-align: left;
cursor: pointer;
transition: background 0.15s;
}
.conversation-item:hover {
background: rgba(255, 255, 255, 0.05);
}
.conversation-item.active {
background: rgba(102, 126, 234, 0.15);
border-left: 3px solid #667eea;
}
.conv-title {
color: #e0e0e0;
font-size: 0.95rem;
font-weight: 500;
margin-bottom: 0.25rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.conv-date {
color: #666;
font-size: 0.75rem;
}
</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

@@ -1,155 +1,241 @@
<script lang="ts"> <script lang="ts">
import type { Signal } from '$lib/api'; import type { Signal } from '$lib/api';
import { onMount, tick } from 'svelte';
interface Props { interface Props {
signals: Signal[]; signals?: Signal[];
klines?: { time: number; close: number }[];
height?: number; height?: number;
} }
let { signals, height = 200 }: Props = $props(); let { signals = [], klines = [], height = 200 }: Props = $props();
let width = $state(800); let width = $state(800);
let containerEl: HTMLDivElement; let containerEl: HTMLDivElement;
let canvasEl: HTMLCanvasElement;
let initialized = $state(false);
$effect(() => { onMount(() => {
// Set initial width
if (containerEl) { if (containerEl) {
width = containerEl.clientWidth; width = containerEl.clientWidth;
} }
// Resize observer
const resizeObserver = new ResizeObserver(entries => {
for (const entry of entries) {
width = entry.contentRect.width;
drawChart();
}
}); });
function getSignalPosition(signal: Signal, index: number, total: number): { x: number; y: number } { if (containerEl) {
const padding = 30; resizeObserver.observe(containerEl);
const chartWidth = width - padding * 2;
const chartHeight = height - padding * 2;
const x = padding + (index / Math.max(total - 1, 1)) * chartWidth;
const priceRange = getPriceRange();
const normalizedPrice = priceRange.min === priceRange.max ? 0.5 :
(signal.price - priceRange.min) / (priceRange.max - priceRange.min);
const y = padding + (1 - normalizedPrice) * chartHeight;
return { x, y };
} }
function getPriceRange(): { min: number; max: number } { initialized = true;
if (signals.length === 0) return { min: 0, max: 1 };
const prices = signals.map(s => s.price); return () => {
const min = Math.min(...prices); resizeObserver.disconnect();
const max = Math.max(...prices); };
const padding = (max - min) * 0.1 || 1; });
return { min: min - padding, max: max + padding };
// 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;
} }
function getSignalColor(signal: Signal): string { const ctx = canvasEl.getContext('2d');
switch (signal.signal_type) { if (!ctx) return;
case 'buy': return '#22c55e';
case 'sell': return '#ef4444'; const dpr = window.devicePixelRatio || 1;
case 'hold': return '#fbbf24'; canvasEl.width = width * dpr;
default: return '#888'; 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;
} }
} }
function getYAxisLabels(): string[] { const x = indexToX(closestIndex);
const range = getPriceRange(); const y = priceToY(signalPrice);
const step = (range.max - range.min) / 4; const color = signal.signal_type === 'buy' ? '#22c55e' : '#ef4444';
return [
range.max.toFixed(6), // Vertical dashed line
(range.max - step).toFixed(6), ctx.beginPath();
(range.min + step).toFixed(6), ctx.strokeStyle = color;
range.min.toFixed(6) 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();
});
} }
function getXAxisLabels(): string[] { // Draw X axis time labels
if (signals.length === 0) return []; ctx.fillStyle = '#666';
const step = Math.max(1, Math.floor(signals.length / 5)); ctx.font = '9px monospace';
const labels: string[] = []; ctx.textAlign = 'center';
for (let i = 0; i < signals.length; i += step) {
labels.push(new Date(signals[i].created_at).toLocaleTimeString()); 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);
} }
return labels;
} }
</script> </script>
<div class="signal-chart" bind:this={containerEl}> <div class="signal-chart" bind:this={containerEl}>
{#if signals.length === 0} {#if klines.length === 0 && signals.length === 0}
<div class="empty-state"> <div class="empty-state">
<p>No signals to display</p> <p>No data to display. Start a simulation to see price movements.</p>
</div> </div>
{:else} {:else}
<svg {width} {height} viewBox="0 0 {width} {height}"> <canvas
<defs> bind:this={canvasEl}
<linearGradient id="chartGradient" x1="0" y1="0" x2="0" y2="1"> style="width: 100%; height: {height}px;"
<stop offset="0%" stop-color="rgba(102, 126, 234, 0.3)" /> ></canvas>
<stop offset="100%" stop-color="rgba(102, 126, 234, 0)" />
</linearGradient>
</defs>
<g class="grid-lines">
{#each [0, 1, 2, 3] as i}
{@const y = 30 + (i / 3) * (height - 60)}
<line
x1="30" y1={y}
x2={width - 30} y2={y}
stroke="rgba(255,255,255,0.1)"
stroke-dasharray="4,4"
/>
{/each}
</g>
<g class="y-axis">
{#each getYAxisLabels() as label, i}
{@const y = 30 + (i / 3) * (height - 60)}
<text x="25" y={y + 4} class="axis-label" text-anchor="end">${label}</text>
{/each}
</g>
<g class="x-axis">
{#each getXAxisLabels() as label, i}
{@const x = 30 + (i / (getXAxisLabels().length - 1 || 1)) * (width - 60)}
<text x={x} y={height - 8} class="axis-label" text-anchor="middle">{label}</text>
{/each}
</g>
<path
d={signals.map((s, i) => {
const pos = getSignalPosition(s, i, signals.length);
return `${i === 0 ? 'M' : 'L'} ${pos.x} ${pos.y}`;
}).join(' ')}
fill="none"
stroke="#667eea"
stroke-width="2"
/>
{#each signals as signal, i}
{@const pos = getSignalPosition(signal, i, signals.length)}
{@const color = getSignalColor(signal)}
<circle
cx={pos.x}
cy={pos.y}
r="6"
fill={color}
stroke={color}
stroke-width="2"
class="signal-dot"
>
<title>{signal.signal_type.toUpperCase()} - ${signal.price.toFixed(6)} - {new Date(signal.created_at).toLocaleString()}</title>
</circle>
{/each}
</svg>
<div class="legend">
<div class="legend-item">
<span class="legend-dot buy"></span>
<span>Buy</span>
</div>
<div class="legend-item">
<span class="legend-dot sell"></span>
<span>Sell</span>
</div>
<div class="legend-item">
<span class="legend-dot hold"></span>
<span>Hold</span>
</div>
</div>
{/if} {/if}
</div> </div>
@@ -169,60 +255,12 @@
justify-content: center; justify-content: center;
height: 200px; height: 200px;
color: #666; color: #666;
text-align: center;
padding: 1rem;
} }
svg { canvas {
display: block; display: block;
width: 100%; width: 100%;
height: auto;
}
.axis-label {
font-size: 10px;
fill: #666;
}
.signal-dot {
cursor: pointer;
transition: r 0.2s;
}
.signal-dot:hover {
r: 8;
}
.legend {
display: flex;
justify-content: center;
gap: 1.5rem;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: #888;
}
.legend-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.legend-dot.buy {
background: #22c55e;
}
.legend-dot.sell {
background: #ef4444;
}
.legend-dot.hold {
background: #fbbf24;
} }
</style> </style>

View File

@@ -10,13 +10,14 @@
let { config, editable = false, onUpdate }: Props = $props(); let { config, editable = false, onUpdate }: Props = $props();
function getConditionDescription(condition: StrategyConfig['conditions'][0]): string { function getConditionDescription(condition: StrategyConfig['conditions'][0]): string {
const timeframe = condition.timeframe ? ` within ${condition.timeframe}` : '';
switch (condition.type) { switch (condition.type) {
case 'price_drop': case 'price_drop':
return `${condition.token} drops by ${condition.threshold}% within ${condition.timeframe}`; return `${condition.token} drops by ${condition.threshold}%${timeframe}`;
case 'price_rise': case 'price_rise':
return `${condition.token} rises by ${condition.threshold}% within ${condition.timeframe}`; return `${condition.token} rises by ${condition.threshold}%${timeframe}`;
case 'volume_spike': case 'volume_spike':
return `${condition.token} volume spikes by ${condition.threshold}% within ${condition.timeframe}`; return `${condition.token} volume spikes by ${condition.threshold}%${timeframe}`;
case 'price_level': case 'price_level':
return `${condition.token} crosses ${condition.direction} $${condition.price}`; return `${condition.token} crosses ${condition.direction} $${condition.price}`;
default: default:

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

@@ -3,7 +3,17 @@ export { default as BotCard } from './BotCard.svelte';
export { default as BotSelector } from './BotSelector.svelte'; export { default as BotSelector } from './BotSelector.svelte';
export { default as StrategyPreview } from './StrategyPreview.svelte'; export { default as StrategyPreview } from './StrategyPreview.svelte';
export { default as SignalChart } from './SignalChart.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 BacktestChart } from './BacktestChart.svelte';
export { default as ProUpgradeBanner } from './ProUpgradeBanner.svelte'; export { default as ProUpgradeBanner } from './ProUpgradeBanner.svelte';
export { default as TokenPicker } from './TokenPicker.svelte'; export { default as TokenPicker } from './TokenPicker.svelte';
export { default as ConditionBuilder } from './ConditionBuilder.svelte'; export { default as ConditionBuilder } from './ConditionBuilder.svelte';
export { default as ChatLayout } from './ChatLayout.svelte';
export { default as ConversationList } from './ConversationList.svelte';
export { default as ChatArea } from './ChatArea.svelte';
export { default as ChatInput } from './ChatInput.svelte';
export { default as BotInfoPanel } from './BotInfoPanel.svelte';
export { default as AnonymousBanner } from './AnonymousBanner.svelte';
export { default as CandlestickLoader } from './CandlestickLoader.svelte';
export { default as AppHeader } from './AppHeader.svelte';

View File

@@ -5,15 +5,29 @@ export interface ChatMessage {
id: string; id: string;
role: 'user' | 'assistant' | 'system'; role: 'user' | 'assistant' | 'system';
content: string; content: string;
thinking: string | null;
timestamp: Date; timestamp: Date;
} }
// Fallback UUID generator for environments where crypto.randomUUID is not available
export 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 const chatStore = writable<ChatMessage[]>([]);
export function addMessage(message: Omit<ChatMessage, 'id' | 'timestamp'>) { export function addMessage(message: Omit<ChatMessage, 'id' | 'timestamp'>) {
const newMessage: ChatMessage = { const newMessage: ChatMessage = {
...message, ...message,
id: crypto.randomUUID(), id: generateId(),
timestamp: new Date() timestamp: new Date()
}; };
chatStore.update(messages => [...messages, newMessage]); chatStore.update(messages => [...messages, newMessage]);
@@ -24,6 +38,7 @@ export function setMessages(messages: BotConversation[]) {
id: m.id, id: m.id,
role: m.role, role: m.role,
content: m.content, content: m.content,
thinking: null,
timestamp: new Date(m.created_at) timestamp: new Date(m.created_at)
}))); })));
} }

View File

@@ -0,0 +1,96 @@
import { writable, derived } from 'svelte/store';
import type { Conversation, Message } from '$lib/api';
export interface ConversationState {
conversations: Conversation[];
currentConversationId: string | null;
messages: Message[];
isLoading: boolean;
error: string | null;
anonymousChatCount: number;
}
const initialState: ConversationState = {
conversations: [],
currentConversationId: null,
messages: [],
isLoading: false,
error: null,
anonymousChatCount: 0
};
export const conversationStore = writable<ConversationState>(initialState);
export function setConversations(conversations: Conversation[]) {
conversationStore.update(state => ({ ...state, conversations }));
}
export function addConversation(conversation: Conversation) {
conversationStore.update(state => ({
...state,
conversations: [conversation, ...state.conversations]
}));
}
export function removeConversation(conversationId: string) {
conversationStore.update(state => ({
...state,
conversations: state.conversations.filter(c => c.id !== conversationId),
currentConversationId: state.currentConversationId === conversationId ? null : state.currentConversationId
}));
}
export function setCurrentConversation(conversationId: string | null) {
conversationStore.update(state => ({
...state,
currentConversationId: conversationId,
messages: []
}));
}
export function setMessages(messages: Message[]) {
conversationStore.update(state => ({ ...state, messages }));
}
export function addMessage(message: Message) {
conversationStore.update(state => ({
...state,
messages: [...state.messages, message]
}));
}
export function setLoading(isLoading: boolean) {
conversationStore.update(state => ({ ...state, isLoading }));
}
export function setError(error: string | null) {
conversationStore.update(state => ({ ...state, error }));
}
export function incrementAnonymousChatCount() {
conversationStore.update(state => ({
...state,
anonymousChatCount: state.anonymousChatCount + 1
}));
}
export function resetAnonymousChatCount() {
conversationStore.update(state => ({
...state,
anonymousChatCount: 0
}));
}
export function updateConversationTitle(conversationId: string, title: string) {
conversationStore.update(state => ({
...state,
conversations: state.conversations.map(c =>
c.id === conversationId ? { ...c, title, updated_at: new Date().toISOString() } : c
)
}));
}
export const currentConversation = derived(
conversationStore,
$state => $state.conversations.find(c => c.id === $state.currentConversationId) ?? null
);

View File

@@ -28,3 +28,18 @@ export {
register, register,
logout logout
} from './authStore'; } from './authStore';
export {
conversationStore,
setConversations,
addConversation,
removeConversation,
setCurrentConversation,
setMessages as setConversationMessages,
addMessage as addConversationMessage,
setLoading as setConversationLoading,
setError as setConversationError,
incrementAnonymousChatCount,
resetAnonymousChatCount,
updateConversationTitle,
currentConversation
} from './conversationStore';

View File

@@ -1,9 +1,35 @@
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import type { Simulation, Signal } from '$lib/api'; 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 { export interface SimulationState {
currentSimulation: Simulation | null; currentSimulation: Simulation | null;
signals: Signal[]; signals: Signal[];
klines: KlineData[];
tradeLog: TradeLogEntry[];
portfolio: Portfolio;
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
} }
@@ -11,6 +37,16 @@ export interface SimulationState {
const initialState: SimulationState = { const initialState: SimulationState = {
currentSimulation: null, currentSimulation: null,
signals: [], signals: [],
klines: [],
tradeLog: [],
portfolio: {
initial_balance: 10000,
current_balance: 10000,
position: 0,
position_token: '',
entry_price: 0,
current_price: 0
},
isLoading: false, isLoading: false,
error: null error: null
}; };
@@ -18,7 +54,20 @@ const initialState: SimulationState = {
export const simulationStore = writable<SimulationState>(initialState); export const simulationStore = writable<SimulationState>(initialState);
export function setCurrentSimulation(simulation: Simulation | null) { export function setCurrentSimulation(simulation: Simulation | null) {
simulationStore.update(state => ({ ...state, currentSimulation: simulation })); 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[]) { export function addSignals(newSignals: Signal[]) {

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

@@ -7,6 +7,13 @@
onMount(() => { onMount(() => {
initAuth(); initAuth();
// Reset anonymous counts on layout load (for debugging)
const count = localStorage.getItem('anonymous_chat_count');
if (count && parseInt(count) >= 50) {
console.log('Resetting anonymous_chat_count from', count, 'to 0');
localStorage.setItem('anonymous_chat_count', '0');
}
}); });
</script> </script>

View File

@@ -16,8 +16,7 @@
<h1>Randebu</h1> <h1>Randebu</h1>
<p class="tagline">Create trading bots through conversation with AI</p> <p class="tagline">Create trading bots through conversation with AI</p>
<div class="cta"> <div class="cta">
<a href="/register" class="btn btn-primary">Get Started</a> <a href="/chat" class="btn btn-primary">Try Now - Free</a>
<a href="/login" class="btn btn-secondary">Login</a>
</div> </div>
</div> </div>

View File

@@ -4,12 +4,20 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { isAuthenticated, isLoading, chatStore, addMessage, setMessages, clearChat, currentBotStore, setCurrentBot } from '$lib/stores'; import { isAuthenticated, isLoading, chatStore, addMessage, setMessages, clearChat, currentBotStore, setCurrentBot } from '$lib/stores';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { ChatInterface, StrategyPreview, ProUpgradeBanner } from '$lib/components'; import { ChatInterface, StrategyPreview } from '$lib/components';
import type { TokenSearchResult } from '$lib/api';
let botId = $derived($page.params.id); let botId = $derived($page.params.id);
let isSending = $state(false); let isSending = $state(false);
let showStrategy = $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 () => { onMount(async () => {
if (!$isAuthenticated && !$isLoading) { if (!$isAuthenticated && !$isLoading) {
goto('/login'); goto('/login');
@@ -44,16 +52,40 @@
isSending = true; isSending = true;
// Add user's message immediately so it shows even before API response
addMessage({ role: 'user', content: message, thinking: null });
try { try {
const response = await api.bots.chat(botId, message); // Add timeout to prevent hanging requests
addMessage({ role: 'assistant', content: response.response }); 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) { if (response.strategy_config) {
const bot = await api.bots.get(botId); const bot = await api.bots.get(botId);
setCurrentBot(bot); setCurrentBot(bot);
} }
} catch (e) { } catch (e) {
addMessage({ role: 'assistant', content: 'Sorry, I encountered an error. Please try again.' }); 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 { } finally {
isSending = false; isSending = false;
} }
@@ -62,6 +94,62 @@
function toggleStrategy() { function toggleStrategy() {
showStrategy = !showStrategy; 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> </script>
<svelte:head> <svelte:head>
@@ -69,6 +157,34 @@
</svelte:head> </svelte:head>
<main> <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> <header>
<div class="header-left"> <div class="header-left">
<a href="/dashboard" class="back-link">← Dashboard</a> <a href="/dashboard" class="back-link">← Dashboard</a>
@@ -95,12 +211,12 @@
<ChatInterface <ChatInterface
bot={$currentBotStore} bot={$currentBotStore}
messages={$chatStore} messages={$chatStore}
{isSending} isSending={isSending}
onSendMessage={handleSendMessage} onSendMessage={handleSendMessage}
/> />
</div> </div>
<ProUpgradeBanner feature="Auto-execute trades with your bot" /> <!-- <ProUpgradeBanner feature="Auto-execute trades with your bot" /> -->
</main> </main>
<style> <style>
@@ -186,4 +302,145 @@
display: flex; display: flex;
flex-direction: column; 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> </style>

View File

@@ -8,14 +8,36 @@
import type { Backtest } from '$lib/api'; import type { Backtest } from '$lib/api';
let botId = $derived($page.params.id); let botId = $derived($page.params.id);
let token = $state('PEPE'); let tokenName = $state('');
let tokenAddress = $state('');
let timeframe = $state('1h'); let timeframe = $state('1h');
let startDate = $state(''); let startDate = $state('');
let endDate = $state(''); let endDate = $state('');
let isRunning = $state(false); let isRunning = $state(false);
let selectedBacktest = $state<Backtest | null>(null); 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 () => { 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) { if (!$isAuthenticated && !$isLoading) {
goto('/login'); goto('/login');
return; return;
@@ -30,6 +52,16 @@
try { try {
const bot = await api.bots.get(botId); const bot = await api.bots.get(botId);
setCurrentBot(bot); 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) { } catch (e) {
goto('/dashboard'); goto('/dashboard');
} }
@@ -46,13 +78,25 @@
async function startBacktest() { async function startBacktest() {
if (!startDate || !endDate) return; 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); setBacktestError(null);
setBacktestLoading(true); setBacktestLoading(true);
isRunning = true; isRunning = true;
try { try {
const backtest = await api.backtest.start(botId, { const backtest = await api.backtest.start(botId, {
token, token: tokenAddress, // Use token address from strategy
token_name: tokenName, // Also send token name for display
timeframe, timeframe,
start_date: startDate, start_date: startDate,
end_date: endDate end_date: endDate
@@ -76,15 +120,54 @@
} }
} }
function setBacktestHistory(backtests: any[]) { function setBacktestHistory(backtests: any[]) {
backtestStore.update(state => ({ ...state, backtestHistory: backtests })); backtestStore.update(state => ({ ...state, backtestHistory: backtests }));
} }
function selectBacktest(backtest: Backtest) { function selectBacktest(backtest: Backtest) {
if (backtest.status === 'completed' && backtest.result) { if (backtest.status === 'completed' && backtest.result && !backtest.result.error) {
selectedBacktest = backtest; 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> </script>
<svelte:head> <svelte:head>
@@ -109,17 +192,19 @@
<form onsubmit={(e) => { e.preventDefault(); startBacktest(); }}> <form onsubmit={(e) => { e.preventDefault(); startBacktest(); }}>
<div class="form-row"> <div class="form-row">
<div class="field"> <div class="field token-info">
<label for="token">Token</label> <label>Token</label>
<input type="text" id="token" bind:value={token} required /> <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>
<div class="field"> <div class="field">
<label for="timeframe">Timeframe</label> <label for="timeframe">Timeframe</label>
<select id="timeframe" bind:value={timeframe}> <select id="timeframe" bind:value={timeframe}>
<option value="1m">1 minute</option> <option value="1h">1 hour (recommended)</option>
<option value="5m">5 minutes</option>
<option value="15m">15 minutes</option>
<option value="1h">1 hour</option>
<option value="4h">4 hours</option> <option value="4h">4 hours</option>
<option value="1d">1 day</option> <option value="1d">1 day</option>
</select> </select>
@@ -144,7 +229,12 @@
</section> </section>
<section class="results-section"> <section class="results-section">
<div class="section-header">
<h2>Backtest History</h2> <h2>Backtest History</h2>
<button class="btn-refresh" onclick={() => loadBacktests()} disabled={$backtestStore.isLoading}>
{$backtestStore.isLoading ? 'Refreshing...' : 'Refresh'}
</button>
</div>
{#if $backtestStore.backtestHistory.length === 0} {#if $backtestStore.backtestHistory.length === 0}
<p class="empty-state">No backtests yet. Run your first backtest above.</p> <p class="empty-state">No backtests yet. Run your first backtest above.</p>
@@ -156,7 +246,11 @@
<span class="backtest-status status-{backtest.status}">{backtest.status}</span> <span class="backtest-status status-{backtest.status}">{backtest.status}</span>
<span class="backtest-date">{new Date(backtest.started_at).toLocaleDateString()}</span> <span class="backtest-date">{new Date(backtest.started_at).toLocaleDateString()}</span>
</div> </div>
{#if backtest.result} {#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="backtest-results">
<div class="result-item"> <div class="result-item">
<span class="result-label">Total Return</span> <span class="result-label">Total Return</span>
@@ -177,16 +271,68 @@
<span class="result-value negative">{backtest.result.max_drawdown.toFixed(2)}%</span> <span class="result-value negative">{backtest.result.max_drawdown.toFixed(2)}%</span>
</div> </div>
</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}
{#if backtest.status === 'running'} {#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> <button onclick={() => stopBacktest(backtest.id)} class="btn btn-danger">Stop</button>
{/if} {/if}
</div> </div>
{/each} {/each}
</div> </div>
{/if} {/if}
</section>
{#if selectedBacktest} {#if selectedBacktest}
<section class="chart-section"> <section class="chart-section">
<div class="chart-header"> <div class="chart-header">
@@ -196,6 +342,8 @@
<BacktestChart results={selectedBacktest.result} /> <BacktestChart results={selectedBacktest.result} />
</section> </section>
{/if} {/if}
</div> </div>
</main> </main>
@@ -237,7 +385,120 @@
h2 { h2 {
font-size: 1.25rem; font-size: 1.25rem;
margin: 0 0 1rem; 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 { .content {
@@ -262,6 +523,20 @@
font-size: 0.9rem; 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 { .form-row {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@@ -275,6 +550,27 @@
gap: 0.5rem; 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 { label {
font-size: 0.9rem; font-size: 0.9rem;
color: #ccc; color: #ccc;
@@ -334,6 +630,83 @@
padding: 1rem; 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 { .backtest-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -364,6 +737,11 @@
color: #fca5a5; color: #fca5a5;
} }
.status-stopped {
background: rgba(251, 191, 36, 0.2);
color: #fbbf24;
}
.backtest-date { .backtest-date {
color: #888; color: #888;
font-size: 0.85rem; font-size: 0.85rem;
@@ -375,6 +753,24 @@
gap: 1rem; 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 { .result-item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -399,6 +795,33 @@
color: #ef4444; 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 { .btn-danger {
margin-top: 0.75rem; margin-top: 0.75rem;
width: auto; width: auto;
@@ -439,4 +862,61 @@
background: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.2);
color: #fff; 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> </style>

View File

@@ -4,13 +4,14 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { isAuthenticated, isLoading, currentBotStore, setCurrentBot, simulationStore, setCurrentSimulation, addSignals, clearSignals, setSimulationLoading, setSimulationError } from '$lib/stores'; import { isAuthenticated, isLoading, currentBotStore, setCurrentBot, simulationStore, setCurrentSimulation, addSignals, clearSignals, setSimulationLoading, setSimulationError } from '$lib/stores';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { SignalChart, ProUpgradeBanner } from '$lib/components'; import { SignalChart, TradeDashboard, PortfolioSummary } from '$lib/components';
let botId = $derived($page.params.id); let botId = $derived($page.params.id);
let token = $state('PEPE'); let tokenName = $state('');
let intervalSeconds = $state(60); let tokenAddress = $state('');
let autoExecute = $state(false); let klineInterval = $state('1m');
let isRunning = $state(false); let isRunning = $state(false);
let isRefreshing = $state(false);
onMount(async () => { onMount(async () => {
if (!$isAuthenticated && !$isLoading) { if (!$isAuthenticated && !$isLoading) {
@@ -27,26 +28,40 @@
try { try {
const bot = await api.bots.get(botId); const bot = await api.bots.get(botId);
setCurrentBot(bot); 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) { } catch (e) {
goto('/dashboard'); goto('/dashboard');
} }
} }
async function loadSimulations() { async function loadSimulations() {
isRefreshing = true;
try { try {
const simulations = await api.simulate.list(botId); const simulations = await api.simulate.list(botId);
if (simulations.length > 0) {
const latest = simulations[0]; // Find the most recent running simulation, or fall back to most recent
setCurrentSimulation(latest); let current = simulations.find(s => s.status === 'running') || simulations[0];
if (latest.signals) {
addSignals(latest.signals); if (current) {
} setCurrentSimulation(current);
if (latest.status === 'running') { clearSignals();
isRunning = true; if (current.signals && current.signals.length > 0) {
addSignals(current.signals);
} }
isRunning = current.status === 'running';
} }
} catch (e) { } catch (e) {
console.error('Failed to load simulations:', e); console.error('Failed to load simulations:', e);
} finally {
isRefreshing = false;
} }
} }
@@ -57,9 +72,9 @@
try { try {
const simulation = await api.simulate.start(botId, { const simulation = await api.simulate.start(botId, {
token, token: tokenAddress,
interval_seconds: intervalSeconds, chain: 'bsc',
auto_execute: autoExecute kline_interval: klineInterval
}); });
setCurrentSimulation(simulation); setCurrentSimulation(simulation);
clearSignals(); clearSignals();
@@ -94,11 +109,23 @@
<a href="/bot/{botId}" class="back-link">← Back to Chat</a> <a href="/bot/{botId}" class="back-link">← Back to Chat</a>
<h1>Simulation</h1> <h1>Simulation</h1>
</div> </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> </header>
<div class="notice"> <div class="notice">
<span class="notice-icon">⚠️</span> <span class="notice-icon">⚠️</span>
<span>Simulation Mode - Using REST polling (every {intervalSeconds}s). For real-time signals, consider upgrading to Pro tier.</span> <span>Simulation Mode - Running on {klineInterval} kline data. Stop simulation to see results.</span>
</div> </div>
<div class="content"> <div class="content">
@@ -111,26 +138,26 @@
<form onsubmit={(e) => { e.preventDefault(); startSimulation(); }}> <form onsubmit={(e) => { e.preventDefault(); startSimulation(); }}>
<div class="form-row"> <div class="form-row">
<div class="field"> <div class="field token-info">
<label for="token">Token</label> <label>Token</label>
<input type="text" id="token" bind:value={token} required disabled={isRunning} /> <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>
<div class="field"> <div class="field">
<label for="interval">Check Interval (seconds)</label> <label for="klineInterval">Kline Interval</label>
<select id="interval" bind:value={intervalSeconds} disabled={isRunning}> <select id="klineInterval" bind:value={klineInterval} disabled={isRunning}>
<option value={30}>30 seconds</option> <option value="1m">1 minute</option>
<option value={60}>60 seconds</option> <option value="5m">5 minutes</option>
<option value={120}>2 minutes</option> <option value="15m">15 minutes</option>
<option value={300}>5 minutes</option> <option value="1h">1 hour</option>
</select> </select>
</div> </div>
</div> </div>
<div class="field checkbox-field">
<input type="checkbox" id="autoExecute" bind:checked={autoExecute} disabled={isRunning} />
<label for="autoExecute">Auto-execute trades (requires Pro tier)</label>
</div>
{#if isRunning} {#if isRunning}
<button type="button" onclick={stopSimulation} class="btn btn-danger"> <button type="button" onclick={stopSimulation} class="btn btn-danger">
Stop Simulation Stop Simulation
@@ -143,16 +170,31 @@
</form> </form>
</section> </section>
<ProUpgradeBanner feature="Real-time WebSocket signals for instant trading decisions" />
<section class="signals-section"> <section class="signals-section">
<h2>Signals ({$simulationStore.signals.length})</h2> <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} {#if $simulationStore.signals.length === 0}
<p class="empty-state">No signals yet. Start a simulation to see trading signals.</p> <p class="empty-state">No signals generated. The chart above shows price movement.</p>
{:else} {:else}
<SignalChart signals={$simulationStore.signals} height={200} />
<div class="signals-list"> <div class="signals-list">
{#each $simulationStore.signals as signal} {#each $simulationStore.signals as signal}
<div class="signal-card"> <div class="signal-card">
@@ -198,6 +240,42 @@
padding: 2rem; 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 { header {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
@@ -276,6 +354,27 @@
gap: 0.5rem; 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 { .checkbox-field {
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;

View File

@@ -0,0 +1,110 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import { isAuthenticated } from '$lib/stores';
import ChatLayout from '$lib/components/ChatLayout.svelte';
import ConversationList from '$lib/components/ConversationList.svelte';
import AnonymousBanner from '$lib/components/AnonymousBanner.svelte';
import AppHeader from '$lib/components/AppHeader.svelte';
let anonymousChatCount = $state(0);
async function createNewChat() {
try {
const conv = await api.conversations.create();
goto(`/chat/${conv.id}`);
} catch (e) {
console.error('Failed to create conversation:', e);
}
}
</script>
<svelte:head>
<title>Chat - Randebu</title>
</svelte:head>
<main>
<AppHeader />
{#if !$isAuthenticated}
<AnonymousBanner chatCount={anonymousChatCount} />
{/if}
<div class="content">
<ChatLayout leftPane={ConversationList}>
<div class="empty-state">
<div class="empty-content">
<h2>Start a Conversation</h2>
<p>Select a conversation from the sidebar or create a new one</p>
<button class="btn btn-primary" onclick={createNewChat}>
+ New Chat
</button>
</div>
</div>
</ChatLayout>
</div>
</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;
}
.content {
flex: 1;
display: flex;
overflow: hidden;
}
.empty-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0f0f0f 0%, #1a1a2e 100%);
}
.empty-content {
text-align: center;
}
h2 {
font-size: 1.5rem;
margin: 0 0 0.5rem;
color: #fff;
}
p {
font-size: 1rem;
color: #888;
margin: 0 0 1.5rem;
}
.btn {
padding: 0.75rem 1.5rem;
border-radius: 12px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
border: none;
transition: transform 0.2s;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn:hover {
transform: translateY(-2px);
}
</style>

View File

@@ -0,0 +1,585 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import { isAuthenticated } from '$lib/stores';
import type { Message, Bot, ConversationWithMessages } from '$lib/api';
import { generateId, type ChatMessage } from '$lib/stores/chatStore';
import ChatLayout from '$lib/components/ChatLayout.svelte';
import ConversationList from '$lib/components/ConversationList.svelte';
import ChatInterface from '$lib/components/ChatInterface.svelte';
import BotInfoPanel from '$lib/components/BotInfoPanel.svelte';
import AnonymousBanner from '$lib/components/AnonymousBanner.svelte';
import AppHeader from '$lib/components/AppHeader.svelte';
import CandlestickLoader from '$lib/components/CandlestickLoader.svelte';
let conversationId = $derived($page.params.conversationId);
let messages = $state<ChatMessage[]>([]);
let currentBot = $state<Bot | null>(null);
let isLoading = $state(false);
let error = $state<string | null>(null);
let isSending = $state(false);
let anonymousChatCount = $state(0);
let hasShownLoginPrompt = $state(false); // Track if we've already shown the login prompt
let isBlocked = $state(false); // Track if user is blocked due to limits
let blockedReason = $state<string | null>(null); // Reason for being blocked
// Initialize from localStorage
$effect(() => {
const storedCount = localStorage.getItem('anonymous_chat_count');
if (storedCount) {
anonymousChatCount = parseInt(storedCount, 10);
}
const shownPrompt = localStorage.getItem('shown_login_prompt');
hasShownLoginPrompt = shownPrompt === 'true';
});
let currentConversation = $state<ConversationWithMessages | null>(null);
let showBacktestModal = $state(false); // Show backtest configuration modal
let showSimulateModal = $state(false); // Show simulation configuration modal
// Convert Message[] to ChatMessage[] for ChatInterface
function convertMessages(apiMessages: Message[]): ChatMessage[] {
return apiMessages.map(msg => ({
id: msg.id,
role: msg.role,
content: msg.content,
thinking: null,
timestamp: new Date(msg.created_at)
}));
}
$effect(() => {
if (conversationId) {
loadConversation(conversationId);
} else {
messages = [];
currentBot = null;
}
});
async function loadConversation(id: string) {
isLoading = true;
error = null;
try {
// First, try to load as bot ID (direct bot link from dashboard)
try {
const bot = await api.bots.get(id);
currentBot = bot;
messages = [];
currentConversation = null;
isLoading = false;
return;
} catch {
// Not a bot, continue to try conversation
}
// Try to load as conversation
const conv = await api.conversations.get(id);
currentConversation = conv;
messages = convertMessages(conv.messages);
if (conv.bot_id) {
try {
currentBot = await api.bots.get(conv.bot_id);
} catch (e) {
console.error('Failed to load bot:', e);
currentBot = null;
}
} else {
currentBot = null;
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load conversation';
messages = [];
currentBot = null;
} finally {
isLoading = false;
}
}
async function handleSendMessage(message: string) {
if (!conversationId) return;
if (!$isAuthenticated) {
if (anonymousChatCount >= 50) {
// Show confirmation dialog before redirecting
isBlocked = true;
blockedReason = 'message_limit';
error = 'You have reached the maximum of 50 messages. Login to continue with unlimited messages.';
return; // Don't redirect automatically, show blocked state instead
}
// Show login prompt only once when reaching warning threshold (40 messages)
if (anonymousChatCount >= 40 && !hasShownLoginPrompt) {
hasShownLoginPrompt = true;
localStorage.setItem('shown_login_prompt', 'true');
const proceed = confirm('You are about to reach your message limit (40/50). Login now to save your progress and continue chatting.');
if (proceed) {
goto('/login');
return;
}
}
}
isSending = true;
error = null;
// Optimistically add user's message to the chat
const userMessage: ChatMessage = {
id: generateId(),
role: 'user',
content: message,
thinking: null,
timestamp: new Date()
};
messages = [...messages, userMessage];
try {
let updatedConv;
// If we have a bot loaded directly (from dashboard), use bot chat
// Otherwise, use conversation chat
console.log('handleSendMessage: currentBot=', currentBot, 'conversationId=', conversationId);
if (currentBot) {
console.log('Using bot chat for bot:', currentBot.id);
updatedConv = await api.bots.chat(currentBot.id, message);
// Bot chat returns the assistant response directly
const assistantMessage: ChatMessage = {
id: generateId(),
role: 'assistant',
content: updatedConv.response,
thinking: updatedConv.thinking || null,
timestamp: new Date()
};
messages = [...messages, assistantMessage];
} else {
updatedConv = await api.conversations.chat(conversationId, message);
messages = convertMessages(updatedConv.messages);
currentConversation = updatedConv;
if (updatedConv.bot_id && (!currentBot || currentBot.id !== updatedConv.bot_id)) {
try {
currentBot = await api.bots.get(updatedConv.bot_id);
} catch (e) {
console.error('Failed to load bot:', e);
}
}
}
if (!$isAuthenticated) {
anonymousChatCount++;
localStorage.setItem('anonymous_chat_count', String(anonymousChatCount));
}
} catch (e: any) {
console.error('=== CHAT ERROR CAUGHT ===', e);
console.error('Error status:', e.status);
console.error('Error message:', e.message);
// Remove the optimistic user message if the request failed
messages = messages.filter(m => m.id !== userMessage.id);
const status = e.status || (e.response && e.response.status);
const errorMsg = e.message || (e.response && e.response.data && e.response.data.detail) || 'Unknown error';
if (status === 429) {
error = 'Rate limited from the agent service. Please come back later.';
} else if (status === 403 || status === 401) {
// Check which limit was hit based on error message
if (errorMsg.includes('bot')) {
isBlocked = true;
blockedReason = 'bot_limit';
error = "You've reached the maximum number of bots (1) as an anonymous user. Login to create more bots.";
} else if (errorMsg.includes('backtest')) {
isBlocked = true;
blockedReason = 'backtest_limit';
error = "You've reached the maximum number of backtests (1) as an anonymous user. Login to run more.";
} else {
isBlocked = true;
blockedReason = 'message_limit';
error = "You've reached the maximum number of messages (50) as an anonymous user. Login to continue chatting.";
}
} else {
error = errorMsg || 'Failed to send message';
}
} finally {
isSending = false;
}
}
function handleSelectBot() {
goto('/dashboard');
}
function handleBacktest() {
showBacktestModal = true;
}
function handleSimulate() {
showSimulateModal = true;
}
async function runBacktest(config: { token_address: string; timeframe: string; start_date: string; end_date: string }) {
if (!currentBot) return;
isSending = true;
error = null;
showBacktestModal = false;
// Add user message
const userMessage: ChatMessage = {
id: generateId(),
role: 'user',
content: `/backtest ${config.token_address} ${config.timeframe} ${config.start_date} ${config.end_date}`,
timestamp: new Date()
};
messages = [...messages, userMessage];
try {
const result = await api.backtest.start(currentBot.id, {
token: config.token_address,
timeframe: config.timeframe,
start_date: config.start_date,
end_date: config.end_date
});
// Add bot response
const botMessage: ChatMessage = {
id: generateId(),
role: 'assistant',
content: `Backtest started! ID: ${result.id}\nStatus: ${result.status}`,
timestamp: new Date()
};
messages = [...messages, botMessage];
} catch (e: any) {
messages = messages.filter(m => m.id !== userMessage.id);
error = e.message || 'Failed to start backtest';
} finally {
isSending = false;
}
}
async function runSimulate(config: { token_address: string; kline_interval: string; action: string }) {
if (!currentBot) return;
isSending = true;
error = null;
showSimulateModal = false;
// Add user message
const userMessage: ChatMessage = {
id: generateId(),
role: 'user',
content: `/simulate ${config.action} ${config.token_address} ${config.kline_interval}`,
timestamp: new Date()
};
messages = [...messages, userMessage];
try {
const result = await api.simulate.start(currentBot.id, {
token: config.token_address,
kline_interval: config.kline_interval
});
// Add bot response
const botMessage: ChatMessage = {
id: generateId(),
role: 'assistant',
content: `Simulation ${config.action}! Token: ${config.token_address}\nInterval: ${config.kline_interval}`,
timestamp: new Date()
};
messages = [...messages, botMessage];
} catch (e: any) {
messages = messages.filter(m => m.id !== userMessage.id);
error = e.message || 'Failed to ${config.action} simulation';
} finally {
isSending = false;
}
}
</script>
<svelte:head>
<title>Chat - Randebu</title>
</svelte:head>
<main>
<AppHeader />
{#if !$isAuthenticated}
<AnonymousBanner chatCount={anonymousChatCount} />
{/if}
<div class="content">
<ChatLayout
leftPane={ConversationList}
rightPane={BotInfoPanel}
rightPaneProps={{
bot: currentBot,
onSelectBot: handleSelectBot,
onBacktest: handleBacktest,
onSimulate: handleSimulate
}}
>
{#if error}
<div class="error-banner">
{error}
</div>
{/if}
{#if isLoading}
<div class="loading">
<CandlestickLoader />
</div>
{:else if conversationId}
<ChatInterface
bot={currentBot}
messages={messages}
isSending={isSending}
isBlocked={isBlocked}
blockedReason={blockedReason}
onSendMessage={handleSendMessage}
onSelectBot={handleSelectBot}
onLogin={() => goto('/login')}
/>
{:else}
<div class="empty-state">
Select a conversation or start a new one
</div>
{/if}
</ChatLayout>
<!-- Backtest Modal -->
{#if showBacktestModal}
<div class="modal-overlay" onclick={() => showBacktestModal = false}>
<div class="modal" onclick={(e) => e.stopPropagation()}>
<h3>Run Backtest</h3>
<form onsubmit={(e) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const fd = new FormData(form);
runBacktest({
token_address: fd.get('token_address') as string,
timeframe: fd.get('timeframe') as string,
start_date: fd.get('start_date') as string,
end_date: fd.get('end_date') as string
});
}}>
<div class="form-group">
<label for="bt-token">Token Contract Address</label>
<input type="text" id="bt-token" name="token_address" required placeholder="0x..." />
</div>
<div class="form-row">
<div class="form-group">
<label for="bt-timeframe">Timeframe</label>
<select id="bt-timeframe" name="timeframe">
<option value="1d">1 Day</option>
<option value="4h">4 Hours</option>
<option value="1h">1 Hour</option>
<option value="15m">15 Minutes</option>
</select>
</div>
<div class="form-group">
<label for="bt-start">Start Date</label>
<input type="date" id="bt-start" name="start_date" value="2025-01-01" />
</div>
<div class="form-group">
<label for="bt-end">End Date</label>
<input type="date" id="bt-end" name="end_date" value="2026-01-01" />
</div>
</div>
<div class="modal-buttons">
<button type="button" class="btn-cancel" onclick={() => showBacktestModal = false}>Cancel</button>
<button type="submit" class="btn-submit">Run Backtest</button>
</div>
</form>
</div>
</div>
{/if}
<!-- Simulation Modal -->
{#if showSimulateModal}
<div class="modal-overlay" onclick={() => showSimulateModal = false}>
<div class="modal" onclick={(e) => e.stopPropagation()}>
<h3>Run Simulation</h3>
<form onsubmit={(e) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const fd = new FormData(form);
runSimulate({
token_address: fd.get('token_address') as string,
kline_interval: fd.get('kline_interval') as string,
action: 'start'
});
}}>
<div class="form-group">
<label for="sim-token">Token Contract Address</label>
<input type="text" id="sim-token" name="token_address" required placeholder="0x..." />
</div>
<div class="form-group">
<label for="sim-interval">Kline Interval</label>
<select id="sim-interval" name="kline_interval">
<option value="1m">1 Minute</option>
<option value="5m">5 Minutes</option>
<option value="15m">15 Minutes</option>
<option value="1h">1 Hour</option>
<option value="4h">4 Hours</option>
</select>
</div>
<div class="modal-buttons">
<button type="button" class="btn-cancel" onclick={() => showSimulateModal = false}>Cancel</button>
<button type="submit" class="btn-submit">Start Simulation</button>
</div>
</form>
</div>
</div>
{/if}
</div>
</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;
}
.content {
flex: 1;
display: flex;
overflow: hidden;
}
.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 {
background: #1a1a1a;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 1.5rem;
width: 400px;
max-width: 90vw;
}
.modal h3 {
margin: 0 0 1.25rem;
font-size: 1.1rem;
color: #fff;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
font-size: 0.8rem;
color: #888;
margin-bottom: 0.35rem;
}
.form-group input,
.form-group select {
width: 100%;
padding: 0.6rem 0.75rem;
background: #0f0f0f;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
color: #fff;
font-size: 0.9rem;
box-sizing: border-box;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #667eea;
}
.form-row {
display: flex;
gap: 0.75rem;
}
.form-row .form-group {
flex: 1;
}
.modal-buttons {
display: flex;
gap: 0.75rem;
margin-top: 1.25rem;
}
.btn-cancel {
flex: 1;
padding: 0.7rem;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
color: #ccc;
cursor: pointer;
font-size: 0.9rem;
}
.btn-cancel:hover {
background: rgba(255, 255, 255, 0.05);
}
.btn-submit {
flex: 1;
padding: 0.7rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 6px;
color: white;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
}
.btn-submit:hover {
opacity: 0.9;
}
.error-banner {
padding: 0.75rem 1rem;
background: rgba(220, 38, 38, 0.2);
border-bottom: 1px solid rgba(220, 38, 38, 0.3);
color: #fca5a5;
font-size: 0.9rem;
}
.loading {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.empty-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: #888;
font-size: 1rem;
}
</style>

View File

@@ -40,7 +40,7 @@
showCreateModal = false; showCreateModal = false;
newBotName = ''; newBotName = '';
newBotDescription = ''; newBotDescription = '';
goto(`/bot/${bot.id}`); goto(`/chat/${bot.id}`);
} catch (e) { } catch (e) {
createError = e instanceof Error ? e.message : 'Failed to create bot'; createError = e instanceof Error ? e.message : 'Failed to create bot';
} finally { } finally {
@@ -96,7 +96,7 @@
{:else} {:else}
<div class="bots-grid"> <div class="bots-grid">
{#each $botsStore as bot} {#each $botsStore as bot}
<BotCard {bot} onOpen={(id) => goto(`/bot/${id}`)} onDelete={deleteBot} /> <BotCard {bot} onDelete={deleteBot} />
{/each} {/each}
</div> </div>
{/if} {/if}

View File

@@ -0,0 +1,166 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import { isAuthenticated } from '$lib/stores';
import ChatLayout from '$lib/components/ChatLayout.svelte';
import ConversationList from '$lib/components/ConversationList.svelte';
import AnonymousBanner from '$lib/components/AnonymousBanner.svelte';
import AppHeader from '$lib/components/AppHeader.svelte';
let anonymousChatCount = $state(0);
onMount(async () => {
// Check if there's an anonymous token in cookies
const cookies = document.cookie.split(';');
const anonToken = cookies.find(c => c.trim().startsWith('anonymous_token='));
if (anonToken) {
// Count would be tracked server-side, for now just track locally
const stored = localStorage.getItem('anonymous_chat_count');
anonymousChatCount = stored ? parseInt(stored, 10) : 0;
}
});
</script>
<svelte:head>
<title>Randebu - AI Trading Bot Platform</title>
</svelte:head>
<main>
<AppHeader />
{#if !$isAuthenticated}
<AnonymousBanner chatCount={anonymousChatCount} />
{/if}
<div class="content">
<ChatLayout leftPane={ConversationList}>
<div class="welcome-screen">
<div class="welcome-content">
<h1>Welcome to Randebu</h1>
<p class="subtitle">Create trading bots through conversation with AI</p>
<div class="steps">
<div class="step">
<div class="step-number">1</div>
<div class="step-content">
<h3>Describe Your Strategy</h3>
<p>Tell our AI what kind of trading you want in plain English</p>
</div>
</div>
<div class="step">
<div class="step-number">2</div>
<div class="step-content">
<h3>Backtest & Validate</h3>
<p>Test your strategy against historical data</p>
</div>
</div>
<div class="step">
<div class="step-number">3</div>
<div class="step-content">
<h3>Simulate & Monitor</h3>
<p>Run real-time simulations and watch for signals</p>
</div>
</div>
</div>
<p class="hint">Select a conversation from the left or start a new one to begin</p>
</div>
</div>
</ChatLayout>
</div>
</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;
}
.content {
flex: 1;
display: flex;
overflow: hidden;
}
.welcome-screen {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0f0f0f 0%, #1a1a2e 100%);
}
.welcome-content {
text-align: center;
max-width: 500px;
padding: 2rem;
}
h1 {
font-size: 2.5rem;
margin: 0 0 0.5rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.subtitle {
font-size: 1.2rem;
color: #888;
margin: 0 0 2.5rem;
}
.steps {
display: flex;
flex-direction: column;
gap: 1.5rem;
text-align: left;
}
.step {
display: flex;
align-items: flex-start;
gap: 1rem;
}
.step-number {
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
flex-shrink: 0;
}
.step-content h3 {
margin: 0 0 0.25rem;
font-size: 1.1rem;
color: #fff;
}
.step-content p {
margin: 0;
font-size: 0.9rem;
color: #888;
}
.hint {
margin-top: 2.5rem;
font-size: 0.9rem;
color: #666;
}
</style>