diff --git a/.gitignore b/.gitignore index e5df724..a8baa26 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +/backend/data +/backend/uploads/ + # Environment variables .env .env.local diff --git a/backend/examples/ccxt_integration_example.py b/backend/examples/ccxt_integration_example.py new file mode 100644 index 0000000..b50bc9a --- /dev/null +++ b/backend/examples/ccxt_integration_example.py @@ -0,0 +1,97 @@ +""" +Example: Integrating CCXT DataSource into main.py + +This shows how to register a CCXT exchange datasource in your application. +""" + +# In your backend/src/main.py, add this to the lifespan function: + +""" +from datasource.adapters.ccxt_adapter import CCXTDataSource + +@asynccontextmanager +async def lifespan(app: FastAPI): + global agent_executor + + # Initialize data sources + demo_source = DemoDataSource() + datasource_registry.register("demo", demo_source) + subscription_manager.register_source("demo", demo_source) + + # Add CCXT Binance datasource + binance_source = CCXTDataSource( + exchange_id="binance", + poll_interval=60, # Poll every 60 seconds for updates + ) + datasource_registry.register("binance", binance_source) + subscription_manager.register_source("binance", binance_source) + + # Add CCXT Coinbase datasource + coinbase_source = CCXTDataSource( + exchange_id="coinbase", + poll_interval=60, + ) + datasource_registry.register("coinbase", coinbase_source) + subscription_manager.register_source("coinbase", coinbase_source) + + # Add CCXT Kraken datasource + kraken_source = CCXTDataSource( + exchange_id="kraken", + poll_interval=60, + ) + datasource_registry.register("kraken", kraken_source) + subscription_manager.register_source("kraken", kraken_source) + + logger.info("DataSource infrastructure initialized with demo + CCXT sources") + + # ... rest of initialization ... + + yield + + # Cleanup + await binance_source.close() + await coinbase_source.close() + await kraken_source.close() + # ... rest of cleanup ... +""" + +# Usage examples: + +# 1. Connect to datafeed WebSocket and request Binance data: +""" +{ + "type": "request", + "source": "binance", + "method": "search_symbols", + "params": { + "query": "BTC" + } +} +""" + +# 2. Get historical bars from Binance: +""" +{ + "type": "request", + "source": "binance", + "method": "get_bars", + "params": { + "symbol": "BTC/USDT", + "resolution": "60", + "from_time": 1234567890, + "to_time": 1234567999 + } +} +""" + +# 3. Subscribe to polling updates: +""" +{ + "type": "subscribe", + "source": "binance", + "symbol": "BTC/USDT", + "resolution": "1" +} +""" + +print("See comments above for integration examples!") diff --git a/backend/memory/system_prompt.md b/backend/memory/system_prompt.md new file mode 100644 index 0000000..f657f64 --- /dev/null +++ b/backend/memory/system_prompt.md @@ -0,0 +1,174 @@ +# System Prompt + +You are an AI trading assistant for an AI-native algorithmic trading platform. Your role is to help traders design, implement, and manage trading strategies through natural language interaction. + +## Your Core Identity + +You are a **strategy authoring assistant**, not a strategy executor. You help users: +- Design trading strategies from natural language descriptions +- Interpret chart annotations and technical requirements +- Generate strategy executables (code artifacts) +- Manage and monitor live trading state +- Analyze market data and provide insights + +## Your Capabilities + +### State Management +You have read/write access to synchronized state stores: +- **OrderStore**: Active swap orders and order configurations +- **ChartStore**: Current chart view state (symbol, time range, interval) + - `symbol`: Trading pair currently being viewed (e.g., "BINANCE:BTC/USDT") + - `start_time`: Start of visible chart range (Unix timestamp in seconds) + - `end_time`: End of visible chart range (Unix timestamp in seconds) + - `interval`: Chart interval/timeframe (e.g., "15", "60", "D") +- Use your tools to read current state and update it as needed +- All state changes are automatically synchronized with connected clients + +### Strategy Authoring +- Help users express trading intent through conversation +- Translate natural language to concrete strategy specifications +- Understand technical analysis concepts (support/resistance, indicators, patterns) +- Generate self-contained, deterministic strategy executables +- Validate strategy logic for correctness and safety + +### Data & Analysis +- Access to market data through abstract feed specifications +- Can compute indicators and perform technical analysis +- Understand OHLCV data, order books, and market microstructure +- Interpret unstructured data (news, sentiment, on-chain metrics) + +## Communication Style + +- **Technical & Direct**: Users are knowledgeable traders, be precise +- **Safety First**: Never make destructive changes without confirmation +- **Explain Actions**: When modifying state, explain what you're doing +- **Ask Questions**: If intent is unclear, ask for clarification +- **Concise**: Be brief but complete, avoid unnecessary elaboration + +## Key Principles + +1. **Strategies are Deterministic**: Generated strategies run without LLM involvement at runtime +2. **Local Execution**: The platform runs locally for security; you're design-time only +3. **Schema Validation**: All outputs must conform to platform schemas +4. **Risk Awareness**: Always consider position sizing, exposure limits, and risk management +5. **Versioning**: Every strategy artifact is version-controlled with full auditability + +## Your Limitations + +- You **DO NOT** execute trades directly +- You **DO NOT** have access to live market data in real-time (users provide it) +- You **CANNOT** modify the order kernel or execution layer +- You **SHOULD NOT** make assumptions about user risk tolerance without asking +- You **MUST NOT** provide trading or investment advice + +## Memory & Context + +You have access to: +- Full conversation history with semantic search +- Project documentation (design, architecture, data formats) +- Past strategy discussions and decisions +- Relevant context retrieved automatically based on current conversation + +## Tools Available + +### State Management Tools +- `list_sync_stores()`: See available state stores +- `read_sync_state(store_name)`: Read current state +- `write_sync_state(store_name, updates)`: Update state +- `get_store_schema(store_name)`: Inspect state structure + +### Data Source Tools +- `list_data_sources()`: List available data sources (exchanges) +- `search_symbols(query, type, exchange, limit)`: Search for trading symbols +- `get_symbol_info(source_name, symbol)`: Get metadata for a symbol +- `get_historical_data(source_name, symbol, resolution, from_time, to_time, countback)`: Get historical bars +- **`get_chart_data(countback)`**: Get data for the chart the user is currently viewing + - This is the **preferred** way to access chart data when analyzing what the user is looking at + - Automatically reads ChartStore to determine symbol, timeframe, and visible range + - Returns OHLCV data plus any custom columns for the visible chart range +- **`analyze_chart_data(python_script, countback)`**: Execute Python analysis on current chart data + - Automatically fetches current chart data and converts to pandas DataFrame + - Execute custom Python scripts with access to pandas, numpy, matplotlib + - Captures matplotlib plots as base64 images for display to user + - Returns result DataFrames and any printed output + - **Use this for technical analysis, indicator calculations, statistical analysis, and visualization** + +## Important Behavioral Rules + +### Chart Context Awareness +When a user asks about "this chart", "the chart", "what I'm viewing", or similar references to their current view: +1. **ALWAYS** first use `read_sync_state("ChartStore")` to see what they're viewing +2. **NEVER** ask the user to upload an image or tell you what symbol they're looking at +3. The user is viewing a live trading chart in the UI - you can access what they see via ChartStore +4. After reading ChartStore, you can use `get_chart_data()` to get the actual candle data +5. For technical analysis questions, use `analyze_chart_data()` with Python scripts + +Examples of questions that require checking ChartStore first: +- "Can you see this chart?" +- "What are the swing highs and lows?" +- "Is this in an uptrend?" +- "What's the current price?" +- "Analyze this chart" +- "What am I looking at?" + +### Data Analysis Workflow +1. **Check ChartStore** → Know what the user is viewing +2. **Get data** with `get_chart_data()` → Fetch the actual OHLCV bars +3. **Analyze** with `analyze_chart_data()` → Run Python analysis if needed +4. **Respond** with insights based on the actual data + +## Working with Users + +1. **Understand Intent**: Ask clarifying questions about strategy goals +2. **Design Together**: Collaborate on strategy logic iteratively +3. **Validate**: Ensure strategy makes sense before generating code +4. **Test**: Encourage backtesting and paper trading first +5. **Monitor**: Help users interpret live strategy behavior + +## Example Interactions + +**User**: "Can you see this chart? What are the swing highs and lows?" +**You**: *[uses read_sync_state("ChartStore") to check what they're viewing]* +*[uses get_chart_data() to fetch the candle data]* +*[uses analyze_chart_data with Python script to identify swing points]* +"Yes! You're viewing BINANCE:BTC/USDT on the 15-minute chart. I've analyzed the visible range and identified the following swing points: +- Swing High: $43,420 at 14:15 UTC +- Swing Low: $42,980 at 12:30 UTC +- Current swing high: $43,280 at 16:45 UTC (still forming) +The chart shows..." + +**User**: "Create a mean reversion strategy on ETH with RSI" +**You**: "I'll help you design a mean reversion strategy for ETH using RSI. A few questions: +- What RSI levels indicate oversold/overbought? (e.g., 30/70) +- What timeframe? (e.g., 1h, 15m) +- Position sizing as % of balance? +- Stop loss and take profit levels?" + +**User**: "What am I looking at?" +**You**: *[uses read_sync_state("ChartStore")]* +"You're currently viewing BINANCE:BTC/USDT on a 15-minute chart, looking at the range from 2024-01-15 10:00 to 2024-01-15 18:30." + +**User**: "Show me ETH on the 1-hour chart" +**You**: *[uses write_sync_state("ChartStore", {"chart_state": {"symbol": "BINANCE:ETH/USDT", "interval": "60"}})]* +"I've switched your chart to BINANCE:ETH/USDT on the 1-hour timeframe." + +**User**: "What's the current price?" +**You**: *[uses get_chart_data(countback=1)]* +"Based on your current chart (BINANCE:BTC/USDT, 15min), the latest close price is $43,250.50 as of 14:30 UTC." + +**User**: "Calculate the average price over the visible range" +**You**: *[uses get_chart_data()]* +*[analyzes the returned bars data]* +"Over the visible time range (last 4 hours, 16 candles), the average close price is $43,180.25, with a high of $43,420 and low of $42,980." + +**User**: "Calculate RSI and show me a chart" +**You**: *[uses analyze_chart_data with Python script to calculate RSI and create plot]* +"I've calculated the 14-period RSI for your chart. The current RSI is 58.3, indicating neutral momentum. Here's the chart showing price and RSI over the visible range." *[image displayed to user]* + +**User**: "Is this in an uptrend?" +**You**: *[uses analyze_chart_data to calculate 20/50 moving averages and analyze trend]* +"Yes, based on the moving averages analysis, the chart is in an uptrend. The 20-period SMA ($43,150) is above the 50-period SMA ($42,800), and both are sloping upward. Price is currently trading above both averages." + +--- + +Remember: You are a collaborative partner in strategy design, not an autonomous trader. Always prioritize safety, clarity, and user intent. diff --git a/backend/requirements.txt b/backend/requirements.txt index efcf12f..022d483 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -30,3 +30,7 @@ aiofiles>=24.0.0 # Environment configuration python-dotenv>=1.0.0 + +# Secrets management +cryptography>=42.0.0 +argon2-cffi>=23.0.0 diff --git a/backend/src/agent/tools.py b/backend/src/agent/tools.py index 763c25d..5603c40 100644 --- a/backend/src/agent/tools.py +++ b/backend/src/agent/tools.py @@ -572,8 +572,8 @@ async def analyze_chart_data(python_script: str, countback: Optional[int] = None plot_urls = [] # Determine uploads directory (relative to this file) - uploads_dir = Path(__file__).parent.parent.parent / "uploads" - uploads_dir.mkdir(exist_ok=True) + uploads_dir = Path(__file__).parent.parent.parent / "data" / "uploads" + uploads_dir.mkdir(parents=True, exist_ok=True) try: with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture): diff --git a/backend/src/main.py b/backend/src/main.py index 5698dfb..1cd868f 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -14,7 +14,7 @@ from pydantic import BaseModel import uuid import shutil -from sync.protocol import HelloMessage, PatchMessage +from sync.protocol import HelloMessage, PatchMessage, AuthMessage, AuthResponseMessage from sync.registry import SyncRegistry from gateway.hub import Gateway from gateway.channels.websocket import WebSocketChannel @@ -26,6 +26,7 @@ from schema.chart_state import ChartState from datasource.registry import DataSourceRegistry from datasource.subscription_manager import SubscriptionManager from datasource.websocket_handler import DatafeedWebSocketHandler +from secrets_manager import SecretsStore, InvalidMasterPassword # Configure logging logging.basicConfig( @@ -52,6 +53,9 @@ agent_executor = None datasource_registry = DataSourceRegistry() subscription_manager = SubscriptionManager() +# Global secrets store +secrets_store = SecretsStore() + @asynccontextmanager async def lifespan(app: FastAPI): @@ -76,8 +80,19 @@ async def lifespan(app: FastAPI): logger.warning(f"CCXT not available: {e}. Only demo source will be available.") logger.info("To use real exchange data, install ccxt: pip install ccxt>=4.0.0") - # Get API keys from environment - anthropic_api_key = os.environ.get("ANTHROPIC_API_KEY") + # Get API keys from secrets store if unlocked, otherwise fall back to environment + anthropic_api_key = None + + if secrets_store.is_unlocked: + anthropic_api_key = secrets_store.get("ANTHROPIC_API_KEY") + if anthropic_api_key: + logger.info("Loaded API key from encrypted secrets store") + + # Fall back to environment variable + if not anthropic_api_key: + anthropic_api_key = os.environ.get("ANTHROPIC_API_KEY") + if anthropic_api_key: + logger.info("Loaded API key from environment") if not anthropic_api_key: logger.error("ANTHROPIC_API_KEY not found in environment!") @@ -117,8 +132,8 @@ async def lifespan(app: FastAPI): app = FastAPI(lifespan=lifespan) # Create uploads directory -UPLOAD_DIR = Path(__file__).parent.parent / "uploads" -UPLOAD_DIR.mkdir(exist_ok=True) +UPLOAD_DIR = Path(__file__).parent.parent / "data" / "uploads" +UPLOAD_DIR.mkdir(parents=True, exist_ok=True) # Mount static files for serving uploads app.mount("/uploads", StaticFiles(directory=str(UPLOAD_DIR)), name="uploads") @@ -142,14 +157,6 @@ registry.register(chart_store, store_name="ChartStore") @app.websocket("/ws") async def websocket_endpoint(websocket: WebSocket): await websocket.accept() - registry.websocket = websocket - - # Create WebSocket channel for agent communication - channel_id = f"ws_{id(websocket)}" - client_id = f"client_{id(websocket)}" - logger.info(f"WebSocket connected - channel_id: {channel_id}, client_id: {client_id}") - ws_channel = WebSocketChannel(channel_id, websocket, session_id="default") - gateway.register_channel(ws_channel) # Helper function to send responses async def send_response(response): @@ -158,6 +165,168 @@ async def websocket_endpoint(websocket: WebSocket): except Exception as e: logger.error(f"Error sending response: {e}") + # Authentication state + is_authenticated = False + + # Wait for authentication message (must be first message) + try: + auth_timeout = 30 # 30 seconds to authenticate + auth_data = await asyncio.wait_for(websocket.receive_text(), timeout=auth_timeout) + auth_message_json = json.loads(auth_data) + + if auth_message_json.get("type") != "auth": + logger.warning("First message was not auth message") + await send_response(AuthResponseMessage( + success=False, + message="First message must be authentication" + )) + await websocket.close(code=1008, reason="Authentication required") + return + + auth_msg = AuthMessage(**auth_message_json) + logger.info("Received authentication message") + + # Check if secrets store needs initialization + if not secrets_store.is_initialized: + logger.info("Secrets store not initialized, performing first-time setup") + + # Require password confirmation for initialization + if not auth_msg.confirm_password: + await send_response(AuthResponseMessage( + success=False, + needs_confirmation=True, + message="First-time setup: password confirmation required" + )) + await websocket.close(code=1008, reason="Password confirmation required") + return + + if auth_msg.password != auth_msg.confirm_password: + await send_response(AuthResponseMessage( + success=False, + needs_confirmation=True, + message="Passwords do not match" + )) + await websocket.close(code=1008, reason="Password confirmation failed") + return + + # Initialize secrets store + try: + secrets_store.initialize(auth_msg.password) + + # Migrate ANTHROPIC_API_KEY from environment if present + env_key = os.environ.get("ANTHROPIC_API_KEY") + if env_key: + secrets_store.set("ANTHROPIC_API_KEY", env_key) + logger.info("Migrated ANTHROPIC_API_KEY from environment to secrets store") + + is_authenticated = True + await send_response(AuthResponseMessage( + success=True, + message="Secrets store initialized successfully" + )) + logger.info("Secrets store initialized and authenticated") + except Exception as e: + logger.error(f"Failed to initialize secrets store: {e}") + await send_response(AuthResponseMessage( + success=False, + message=f"Initialization failed: {str(e)}" + )) + await websocket.close(code=1011, reason="Initialization failed") + return + else: + # Unlock existing secrets store (or verify password if already unlocked) + try: + # If already unlocked, just verify the password is correct + if secrets_store.is_unlocked: + # Verify password by creating a temporary store and attempting unlock + from secrets_manager import SecretsStore as TempStore + temp_store = TempStore(data_dir=secrets_store.data_dir) + temp_store.unlock(auth_msg.password) # This will throw if wrong password + logger.info("Password verified (store already unlocked)") + else: + secrets_store.unlock(auth_msg.password) + logger.info("Secrets store unlocked successfully") + + # Check if user wants to change password + password_changed = False + if auth_msg.change_to_password: + # Validate password change request + if not auth_msg.confirm_new_password: + await send_response(AuthResponseMessage( + success=False, + message="New password confirmation required" + )) + await websocket.close(code=1008, reason="Password confirmation required") + return + + if auth_msg.change_to_password != auth_msg.confirm_new_password: + await send_response(AuthResponseMessage( + success=False, + message="New passwords do not match" + )) + await websocket.close(code=1008, reason="Password confirmation mismatch") + return + + # Change the password + try: + secrets_store.change_master_password(auth_msg.password, auth_msg.change_to_password) + password_changed = True + logger.info("Master password changed successfully") + except Exception as e: + logger.error(f"Failed to change password: {e}") + await send_response(AuthResponseMessage( + success=False, + message=f"Failed to change password: {str(e)}" + )) + await websocket.close(code=1011, reason="Password change failed") + return + + is_authenticated = True + response_message = "Password changed successfully" if password_changed else "Authentication successful" + await send_response(AuthResponseMessage( + success=True, + password_changed=password_changed, + message=response_message + )) + except InvalidMasterPassword: + logger.warning("Invalid password attempt") + await send_response(AuthResponseMessage( + success=False, + message="Invalid password" + )) + await websocket.close(code=1008, reason="Invalid password") + return + except Exception as e: + logger.error(f"Authentication error: {e}") + await send_response(AuthResponseMessage( + success=False, + message="Authentication failed" + )) + await websocket.close(code=1011, reason="Authentication error") + return + + except asyncio.TimeoutError: + logger.warning("Authentication timeout") + await websocket.close(code=1008, reason="Authentication timeout") + return + except WebSocketDisconnect: + logger.info("Client disconnected during authentication") + return + except Exception as e: + logger.error(f"Error during authentication: {e}") + await websocket.close(code=1011, reason="Authentication error") + return + + # Now authenticated - proceed with normal WebSocket handling + registry.websocket = websocket + + # Create WebSocket channel for agent communication + channel_id = f"ws_{id(websocket)}" + client_id = f"client_{id(websocket)}" + logger.info(f"WebSocket authenticated - channel_id: {channel_id}, client_id: {client_id}") + ws_channel = WebSocketChannel(channel_id, websocket, session_id="default") + gateway.register_channel(ws_channel) + try: while True: data = await websocket.receive_text() diff --git a/backend/src/sync/protocol.py b/backend/src/sync/protocol.py index 2a03f1b..1fea2b1 100644 --- a/backend/src/sync/protocol.py +++ b/backend/src/sync/protocol.py @@ -1,8 +1,24 @@ -from typing import Any, Dict, List, Literal, Union +from typing import Any, Dict, List, Literal, Optional, Union from pydantic import BaseModel +class AuthMessage(BaseModel): + """Authentication message (must be first message from client)""" + type: Literal["auth"] = "auth" + password: str + confirm_password: Optional[str] = None # Required only for initialization + change_to_password: Optional[str] = None # If provided, change password after auth + confirm_new_password: Optional[str] = None # Required if change_to_password is set + +class AuthResponseMessage(BaseModel): + """Authentication response from server""" + type: Literal["auth_response"] = "auth_response" + success: bool + needs_confirmation: bool = False # True if this is first-time setup + password_changed: bool = False # True if password was changed + message: str + class SnapshotMessage(BaseModel): type: Literal["snapshot"] = "snapshot" store: str @@ -20,7 +36,7 @@ class HelloMessage(BaseModel): seqs: Dict[str, int] # Union type for all messages from backend to frontend -BackendMessage = Union[SnapshotMessage, PatchMessage] +BackendMessage = Union[SnapshotMessage, PatchMessage, AuthResponseMessage] # Union type for all messages from frontend to backend -FrontendMessage = Union[HelloMessage, PatchMessage] +FrontendMessage = Union[AuthMessage, HelloMessage, PatchMessage] diff --git a/backend/tests/datafeed_client_example.py b/backend/tests/datafeed_client_example.py new file mode 100644 index 0000000..64e4470 --- /dev/null +++ b/backend/tests/datafeed_client_example.py @@ -0,0 +1,199 @@ +""" +Example client demonstrating how to integrate with the datafeed WebSocket API. + +This shows how a TradingView integration or custom charting client would +interact with the datafeed. +""" + +import asyncio +import json +import time +from typing import Callable, Dict + +import websockets + + +class DatafeedClient: + """Client for TradingView-compatible datafeed WebSocket API""" + + def __init__(self, uri: str = "ws://localhost:8000/ws/datafeed"): + self.uri = uri + self.websocket = None + self.request_id_counter = 0 + self.pending_requests: Dict[str, asyncio.Future] = {} + self.subscriptions: Dict[str, Callable] = {} + self._receive_task = None + + async def connect(self): + """Connect to the datafeed WebSocket""" + self.websocket = await websockets.connect(self.uri) + self._receive_task = asyncio.create_task(self._receive_loop()) + print(f"Connected to {self.uri}") + + async def disconnect(self): + """Disconnect from the datafeed""" + if self._receive_task: + self._receive_task.cancel() + if self.websocket: + await self.websocket.close() + + async def _receive_loop(self): + """Background task to receive and route messages""" + try: + async for message in self.websocket: + data = json.loads(message) + msg_type = data.get("type") + + # Route bar updates to subscription callbacks + if msg_type == "bar_update": + sub_id = data.get("subscription_id") + if sub_id in self.subscriptions: + callback = self.subscriptions[sub_id] + callback(data["bar"]) + # Route responses to pending requests + elif "request_id" in data: + req_id = data["request_id"] + if req_id in self.pending_requests: + future = self.pending_requests.pop(req_id) + future.set_result(data) + except asyncio.CancelledError: + pass + except Exception as e: + print(f"Error in receive loop: {e}") + + def _next_request_id(self) -> str: + """Generate next request ID""" + self.request_id_counter += 1 + return f"req_{self.request_id_counter}" + + async def _send_request(self, request: dict) -> dict: + """Send a request and wait for response""" + req_id = self._next_request_id() + request["request_id"] = req_id + + # Create future for response + future = asyncio.Future() + self.pending_requests[req_id] = future + + # Send request + await self.websocket.send(json.dumps(request)) + + # Wait for response + return await future + + async def get_config(self) -> dict: + """Get datafeed configuration""" + response = await self._send_request({"type": "get_config"}) + return response["config"] + + async def search_symbols(self, query: str) -> list: + """Search for symbols""" + response = await self._send_request({"type": "search_symbols", "query": query}) + return response["results"] + + async def resolve_symbol(self, symbol: str) -> dict: + """Get symbol metadata""" + response = await self._send_request({"type": "resolve_symbol", "symbol": symbol}) + return response["symbol_info"] + + async def get_bars( + self, symbol: str, resolution: str, from_time: int, to_time: int, countback: int = None + ) -> dict: + """Get historical bars""" + request = { + "type": "get_bars", + "symbol": symbol, + "resolution": resolution, + "from_time": from_time, + "to_time": to_time, + } + if countback: + request["countback"] = countback + + response = await self._send_request(request) + return response["history"] + + async def subscribe_bars( + self, symbol: str, resolution: str, subscription_id: str, callback: Callable + ): + """Subscribe to real-time bar updates""" + self.subscriptions[subscription_id] = callback + + response = await self._send_request({ + "type": "subscribe_bars", + "symbol": symbol, + "resolution": resolution, + "subscription_id": subscription_id, + }) + + if not response.get("success"): + raise Exception(f"Subscription failed: {response.get('message')}") + + async def unsubscribe_bars(self, subscription_id: str): + """Unsubscribe from updates""" + self.subscriptions.pop(subscription_id, None) + await self._send_request({ + "type": "unsubscribe_bars", + "subscription_id": subscription_id, + }) + + +async def main(): + """Example usage of the DatafeedClient""" + client = DatafeedClient() + + try: + # Connect + await client.connect() + + # Get config + config = await client.get_config() + print(f"\nDatafeed: {config['name']}") + print(f"Supported resolutions: {config['supported_resolutions']}") + + # Search for BTC + results = await client.search_symbols("BTC") + print(f"\nSearch results for 'BTC': {len(results)} found") + for result in results: + print(f" - {result['symbol']}: {result['description']}") + + # Get symbol info + if results: + symbol = results[0]["symbol"] + info = await client.resolve_symbol(symbol) + print(f"\nSymbol info for {symbol}:") + print(f" Name: {info['name']}") + print(f" Type: {info['type']}") + print(f" Columns: {[c['name'] for c in info['columns']]}") + + # Get historical data + now = int(time.time()) + from_time = now - 3600 # 1 hour ago + history = await client.get_bars(symbol, "5", from_time, now, countback=10) + print(f"\nHistorical data: {len(history['bars'])} bars") + if history["bars"]: + print(f" First bar time: {history['bars'][0]['time']}") + print(f" Last bar close: {history['bars'][-1]['data']['close']}") + + # Subscribe to real-time updates + print(f"\nSubscribing to real-time updates for {symbol}...") + + def on_bar_update(bar): + print(f" [UPDATE] Time: {bar['time']}, Close: {bar['close']}") + + await client.subscribe_bars(symbol, "5", "my_subscription", on_bar_update) + print(" Waiting for updates (15 seconds)...") + await asyncio.sleep(15) + + # Unsubscribe + await client.unsubscribe_bars("my_subscription") + print(" Unsubscribed") + + finally: + await client.disconnect() + + +if __name__ == "__main__": + print("=== Datafeed Client Example ===\n") + print("Make sure the backend server is running on http://localhost:8000") + asyncio.run(main()) diff --git a/backend/tests/test_ccxt_datasource.py b/backend/tests/test_ccxt_datasource.py new file mode 100644 index 0000000..ffaf335 --- /dev/null +++ b/backend/tests/test_ccxt_datasource.py @@ -0,0 +1,156 @@ +""" +Test script for CCXT DataSource adapter (Free Version). + +This demonstrates how to use the free CCXT adapter (not ccxt.pro) with various +exchanges. It uses polling instead of WebSocket for real-time updates and +verifies that Decimal precision is maintained throughout. +""" + +import asyncio +from decimal import Decimal +from datetime import datetime, timedelta + +from src.datasource.adapters.ccxt_adapter import CCXTDataSource + + +async def test_binance_datasource(): + """Test Binance exchange data source""" + print("=" * 60) + print("Testing CCXT DataSource with Binance (Free Version)") + print("=" * 60) + + # Initialize Binance datasource with faster polling for testing + binance = CCXTDataSource(exchange_id="binance", poll_interval=5) + + try: + # Test 1: Get configuration + print("\n1. Getting datafeed configuration...") + config = await binance.get_config() + print(f" Name: {config.name}") + print(f" Description: {config.description}") + print(f" Supported resolutions: {config.supported_resolutions[:5]}...") + print(f" Exchanges: {config.exchanges}") + + # Test 2: Search symbols + print("\n2. Searching for BTC symbols...") + results = await binance.search_symbols("BTC", limit=5) + print(f" Found {len(results)} symbols:") + for result in results[:3]: + print(f" - {result.symbol}: {result.description}") + + # Test 3: Resolve symbol + print("\n3. Resolving symbol metadata for BTC/USDT...") + symbol_info = await binance.resolve_symbol("BTC/USDT") + print(f" Symbol: {symbol_info.symbol}") + print(f" Name: {symbol_info.name}") + print(f" Type: {symbol_info.type}") + print(f" Exchange: {symbol_info.exchange}") + print(f" Columns:") + for col in symbol_info.columns: + print(f" - {col.name} ({col.type}): {col.description}") + + # Test 4: Get historical bars + print("\n4. Fetching historical 1-hour bars for BTC/USDT...") + end_time = int(datetime.now().timestamp()) + start_time = end_time - (24 * 3600) # Last 24 hours + + history = await binance.get_bars( + symbol="BTC/USDT", + resolution="60", # 1 hour + from_time=start_time, + to_time=end_time, + countback=10, + ) + + print(f" Retrieved {len(history.bars)} bars") + if history.bars: + latest = history.bars[-1] + print(f" Latest bar:") + print(f" Time: {datetime.fromtimestamp(latest.time)}") + print(f" Open: {latest.data['open']} (type: {type(latest.data['open']).__name__})") + print(f" High: {latest.data['high']} (type: {type(latest.data['high']).__name__})") + print(f" Low: {latest.data['low']} (type: {type(latest.data['low']).__name__})") + print(f" Close: {latest.data['close']} (type: {type(latest.data['close']).__name__})") + print(f" Volume: {latest.data['volume']} (type: {type(latest.data['volume']).__name__})") + + # Verify Decimal precision + assert isinstance(latest.data['close'], Decimal), "Price should be Decimal type!" + assert isinstance(latest.data['volume'], Decimal), "Volume should be Decimal type!" + print(f" ✓ Numerical precision verified: using Decimal types") + + # Test 5: Polling subscription (brief test) + print("\n5. Testing polling-based subscription...") + print(f" Note: Using free CCXT with {binance._poll_interval}s polling interval") + tick_count = [0] + + def on_tick(data): + tick_count[0] += 1 + if tick_count[0] == 1: + print(f" Received tick: close={data['close']} (type: {type(data['close']).__name__})") + assert isinstance(data['close'], Decimal), "Polled data should use Decimal!" + + subscription_id = await binance.subscribe_bars( + symbol="BTC/USDT", + resolution="1", # 1 minute + on_tick=on_tick, + ) + print(f" Subscription ID: {subscription_id}") + print(f" Waiting {binance._poll_interval + 2} seconds for first poll...") + + await asyncio.sleep(binance._poll_interval + 2) + + # Unsubscribe + await binance.unsubscribe_bars(subscription_id) + print(f" ✓ Subscription test complete (received {tick_count[0]} tick(s))") + + finally: + await binance.close() + print("\n✓ Binance datasource test complete") + + +async def test_multiple_exchanges(): + """Test multiple exchanges""" + print("\n" + "=" * 60) + print("Testing Multiple Exchanges") + print("=" * 60) + + exchanges_to_test = ["binance", "coinbase", "kraken"] + + for exchange_id in exchanges_to_test: + print(f"\nTesting {exchange_id}...") + try: + datasource = CCXTDataSource(exchange_id=exchange_id) + config = await datasource.get_config() + print(f" ✓ {config.name}") + + # Try to search for ETH + results = await datasource.search_symbols("ETH", limit=3) + print(f" ✓ Found {len(results)} ETH symbols") + + await datasource.close() + + except Exception as e: + print(f" ✗ Error: {e}") + + +async def main(): + """Run all tests""" + print("\nCCXT DataSource Adapter Test Suite") + print("=" * 60) + + try: + await test_binance_datasource() + await test_multiple_exchanges() + + print("\n" + "=" * 60) + print("All tests completed successfully! ✓") + print("=" * 60) + + except Exception as e: + print(f"\n✗ Test failed: {e}") + import traceback + traceback.print_exc() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/tests/test_datafeed_websocket.py b/backend/tests/test_datafeed_websocket.py new file mode 100644 index 0000000..76fdb0d --- /dev/null +++ b/backend/tests/test_datafeed_websocket.py @@ -0,0 +1,130 @@ +""" +Test client for TradingView-compatible datafeed WebSocket API. + +Run this to test the datafeed endpoints: + python -m pytest backend/tests/test_datafeed_websocket.py -v -s + +Or run directly: + python backend/tests/test_datafeed_websocket.py +""" + +import asyncio +import json +import time + +import websockets + + +async def test_datafeed(): + """Test datafeed WebSocket API""" + uri = "ws://localhost:8000/ws/datafeed" + + async with websockets.connect(uri) as websocket: + print("✓ Connected to datafeed WebSocket") + + # Test 1: Get config + print("\n--- Test 1: Get Config ---") + request = {"type": "get_config", "request_id": "req_1"} + await websocket.send(json.dumps(request)) + response = json.loads(await websocket.recv()) + print(f"Response: {json.dumps(response, indent=2)}") + assert response["type"] == "get_config_response" + print("✓ Config retrieved") + + # Test 2: Search symbols + print("\n--- Test 2: Search Symbols ---") + request = {"type": "search_symbols", "request_id": "req_2", "query": "BTC"} + await websocket.send(json.dumps(request)) + response = json.loads(await websocket.recv()) + print(f"Response: {json.dumps(response, indent=2)}") + assert response["type"] == "search_symbols_response" + assert len(response["results"]) > 0 + print(f"✓ Found {len(response['results'])} symbols") + + # Test 3: Resolve symbol + print("\n--- Test 3: Resolve Symbol ---") + request = {"type": "resolve_symbol", "request_id": "req_3", "symbol": "DEMO:BTC/USD"} + await websocket.send(json.dumps(request)) + response = json.loads(await websocket.recv()) + print(f"Response: {json.dumps(response, indent=2)}") + assert response["type"] == "resolve_symbol_response" + symbol_info = response["symbol_info"] + print(f"✓ Symbol resolved: {symbol_info['name']}") + print(f" Available columns: {[c['name'] for c in symbol_info['columns']]}") + + # Test 4: Get historical bars + print("\n--- Test 4: Get Historical Bars ---") + now = int(time.time()) + from_time = now - 3600 # 1 hour ago + request = { + "type": "get_bars", + "request_id": "req_4", + "symbol": "DEMO:BTC/USD", + "resolution": "5", + "from_time": from_time, + "to_time": now, + "countback": 10, + } + await websocket.send(json.dumps(request)) + response = json.loads(await websocket.recv()) + print(f"Response type: {response['type']}") + if response["type"] == "get_bars_response": + history = response["history"] + print(f"✓ Received {len(history['bars'])} bars") + if history["bars"]: + print(f" First bar: {history['bars'][0]}") + print(f" Last bar: {history['bars'][-1]}") + else: + print(f"Error: {response}") + + # Test 5: Subscribe to real-time updates + print("\n--- Test 5: Subscribe to Real-time Updates ---") + request = { + "type": "subscribe_bars", + "request_id": "req_5", + "symbol": "DEMO:BTC/USD", + "resolution": "5", + "subscription_id": "sub_1", + } + await websocket.send(json.dumps(request)) + response = json.loads(await websocket.recv()) + print(f"Subscription response: {json.dumps(response, indent=2)}") + assert response["type"] == "subscribe_bars_response" + assert response["success"] is True + print("✓ Subscribed successfully") + + # Wait for a few updates + print("\n Waiting for real-time updates (10 seconds)...") + update_count = 0 + try: + for _ in range(3): # Wait for up to 3 messages + response = await asyncio.wait_for(websocket.recv(), timeout=5.0) + message = json.loads(response) + if message["type"] == "bar_update": + update_count += 1 + print(f" Update {update_count}: {message['bar']}") + except asyncio.TimeoutError: + print(f" No more updates received (got {update_count} updates)") + + # Test 6: Unsubscribe + print("\n--- Test 6: Unsubscribe ---") + request = { + "type": "unsubscribe_bars", + "request_id": "req_6", + "subscription_id": "sub_1", + } + await websocket.send(json.dumps(request)) + response = json.loads(await websocket.recv()) + print(f"Unsubscribe response: {json.dumps(response, indent=2)}") + assert response["type"] == "unsubscribe_bars_response" + assert response["success"] is True + print("✓ Unsubscribed successfully") + + print("\n=== All tests passed! ===") + + +if __name__ == "__main__": + print("Starting datafeed WebSocket tests...") + print("Make sure the backend server is running on http://localhost:8000") + print() + asyncio.run(test_datafeed()) diff --git a/doc/auth.md b/doc/auth.md new file mode 100644 index 0000000..cda0f99 --- /dev/null +++ b/doc/auth.md @@ -0,0 +1,192 @@ +# Authentication & Secrets Management Guide + +## Overview + +Your system now has a complete encrypted secrets management solution with WebSocket authentication. All secrets (like API keys) are stored in an encrypted file, protected by a master password that users enter when connecting. + +## Architecture + +### Backend Components + +1. **Secrets Store** (`backend/src/secrets_manager/`) + - `crypto.py` - Argon2id password hashing + Fernet (AES-256) encryption + - `store.py` - SecretsStore class for managing encrypted secrets + - `cli.py` - Command-line interface for secrets management + +2. **Encrypted Storage** + - `backend/data/secrets.enc` - Encrypted secrets file + - `backend/data/.master.key` - Salt + verification hash (never stores actual password) + - Both files are created with 0600 permissions (owner-only access) + +3. **WebSocket Authentication** (`backend/src/main.py`) + - First message must be `auth` message + - On first use: requires password + confirmation → initializes secrets store + - Subsequent uses: requires password → unlocks secrets store + - Failed auth closes connection immediately + +### Frontend Components + +1. **Login Screen** (`web/src/components/LoginScreen.vue`) + - Shows before WebSocket connection + - Detects first-time setup and shows confirmation field + - Displays error messages for failed authentication + +2. **WebSocket Manager** (`web/src/composables/useWebSocket.ts`) + - Updated to send auth message on connect + - Returns auth result (success/failure) + - Prevents reconnection on auth failure + +3. **App Integration** (`web/src/App.vue`) + - Shows login screen until authenticated + - Initializes state sync only after successful auth + +## Security Features + +✓ **Password-based encryption** - Argon2id (OWASP recommended) +✓ **AES-256 encryption** - Industry-standard Fernet cipher +✓ **Salted passwords** - Unique salt per installation +✓ **No plaintext storage** - Master password never stored +✓ **Restricted permissions** - Secrets files are 0600 (owner-only) +✓ **Constant-time verification** - Prevents timing attacks +✓ **Auto-lock on disconnect** - Secrets cleared from memory + +## Usage + +### First Time Setup + +1. Start backend: `cd backend && python -m uvicorn src.main:app --reload --port 8080` +2. Start frontend: `cd web && npm run dev` +3. Open browser - you'll see "Welcome" screen +4. Create a master password (with confirmation) +5. System automatically migrates `ANTHROPIC_API_KEY` from `.env` to encrypted store + +### Subsequent Logins + +1. Start backend and frontend +2. Enter your master password +3. System unlocks and connects + +### Managing Secrets (CLI) + +```bash +cd backend + +# List all secrets +python -m secrets_manager.cli list + +# Add a new secret +python -m secrets_manager.cli set MY_SECRET "secret-value" + +# Get a secret +python -m secrets_manager.cli get ANTHROPIC_API_KEY + +# Change master password +python -m secrets_manager.cli change-password + +# Backup secrets (encrypted) +python -m secrets_manager.cli export backup.enc + +# Migrate from .env file +python -m secrets_manager.cli migrate-from-env +``` + +### Managing Secrets (Python) + +```python +from secrets_manager import SecretsStore + +# Initialize (first time) +store = SecretsStore() +store.initialize("my-password") + +# Unlock (subsequent times) +store = SecretsStore() +store.unlock("my-password") + +# Use secrets +api_key = store.get("ANTHROPIC_API_KEY") +store.set("NEW_SECRET", "value") +store.delete("OLD_SECRET") + +# Change password +store.change_master_password("old-password", "new-password") +``` + +## Protocol + +### Authentication Flow + +``` +Client → Server: { type: "auth", password: "...", confirm_password: "..." } +Server → Client: { type: "auth_response", success: true, message: "..." } + +# If initialization needed: +Client → Server: { type: "auth", password: "...", confirm_password: "..." } +Server → Client: { type: "auth_response", success: false, needs_confirmation: true, ... } +Client → Server: { type: "auth", password: "same", confirm_password: "same" } +Server → Client: { type: "auth_response", success: true, message: "Initialized" } + +# After successful auth, normal protocol continues: +Client → Server: { type: "hello", seqs: {...} } +Server → Client: { type: "snapshot", ... } +``` + +### Error Codes + +- `1008` - Authentication failed (invalid password) +- `1011` - Internal error during authentication + +## Migration from .env + +The system automatically migrates `ANTHROPIC_API_KEY` from `.env` when you first initialize the secrets store through the web interface. You can also use the CLI: + +```bash +python -m secrets_manager.cli migrate-from-env +# This will ask if you want to delete .env after migration +``` + +## Security Considerations + +1. **Master Password Strength** - Use a strong password (8+ characters recommended) +2. **Backup** - Export encrypted backups regularly: `python -m secrets_manager.cli export backup.enc` +3. **Environment** - Can still fall back to `.env` if secrets store not unlocked (for development) +4. **Transport** - Use HTTPS/WSS in production (currently using HTTP/WS for development) + +## File Locations + +``` +backend/ +├── data/ +│ ├── secrets.enc # Encrypted secrets (created on first auth) +│ ├── .master.key # Salt + verification (created on first auth) +│ └── checkpoints.db # Agent state (existing) +└── src/ + └── secrets/ # Secrets management module + ├── __init__.py + ├── crypto.py # Cryptographic primitives + ├── store.py # SecretsStore class + └── cli.py # Command-line interface + +web/ +└── src/ + ├── components/ + │ └── LoginScreen.vue # Authentication UI + └── composables/ + └── useWebSocket.ts # Updated with auth support +``` + +## Development Tips + +1. **Testing First-Time Setup**: Delete `backend/data/.master.key` to simulate first-time setup +2. **Reset Password**: Delete both `.master.key` and `secrets.enc`, then reconnect +3. **Debug Auth**: Check backend logs for authentication attempts +4. **Bypass Auth (Dev)**: Set `ANTHROPIC_API_KEY` in `.env` and don't initialize secrets store + +## Next Steps + +Consider adding: +- [ ] Password reset mechanism (security questions or backup codes) +- [ ] Session timeout / auto-lock +- [ ] Multi-user support with different passwords +- [ ] Secret versioning / audit log +- [ ] Integration with external secret managers (Vault, AWS Secrets Manager) diff --git a/web/src/App.vue b/web/src/App.vue new file mode 100644 index 0000000..56aa29c --- /dev/null +++ b/web/src/App.vue @@ -0,0 +1,122 @@ + + + + + diff --git a/web/src/components/ChartView.vue b/web/src/components/ChartView.vue new file mode 100644 index 0000000..fe55cfb --- /dev/null +++ b/web/src/components/ChartView.vue @@ -0,0 +1,217 @@ + + + + + diff --git a/web/src/components/ChatPanel.vue b/web/src/components/ChatPanel.vue new file mode 100644 index 0000000..b94b5ca --- /dev/null +++ b/web/src/components/ChatPanel.vue @@ -0,0 +1,309 @@ + + + + + diff --git a/web/src/components/LoginScreen.vue b/web/src/components/LoginScreen.vue new file mode 100644 index 0000000..54c1c19 --- /dev/null +++ b/web/src/components/LoginScreen.vue @@ -0,0 +1,292 @@ + + + + + diff --git a/web/src/composables/useStateSync.ts b/web/src/composables/useStateSync.ts index 63898f3..f2c8390 100644 --- a/web/src/composables/useStateSync.ts +++ b/web/src/composables/useStateSync.ts @@ -102,22 +102,35 @@ export function useStateSync(stores: Record) { sendJson(msg); }; - // Connect to WebSocket and register handler - const ws = wsManager.connect(); - wsManager.addHandler(handleMessage); - console.log('[StateSync] WebSocket ready state:', ws.readyState); - - if (ws.readyState === WebSocket.OPEN) { - console.log('[StateSync] WebSocket already open, sending hello'); - sendHello(); - } else { - console.log('[StateSync] WebSocket not open, waiting for open event'); - ws.addEventListener('open', sendHello, { once: true }); - } - // Set up watchers for each store to send patches on changes const unwatchFunctions: (() => void)[] = []; + // Register handler (connection will be initiated after authentication) + wsManager.addHandler(handleMessage); + + // Send hello when authenticated + const checkAuthAndSendHello = () => { + if (wsManager.isAuthenticated.value && wsManager.isConnected.value) { + console.log('[StateSync] Authenticated and connected, sending hello'); + sendHello(); + } + }; + + // Watch for authentication + const unwatchAuth = wsManager.isAuthenticated.value + ? (checkAuthAndSendHello(), () => {}) + : (() => { + const interval = setInterval(() => { + if (wsManager.isAuthenticated.value && wsManager.isConnected.value) { + clearInterval(interval); + checkAuthAndSendHello(); + } + }, 100); + return () => clearInterval(interval); + })(); + + unwatchFunctions.push(unwatchAuth); + for (const [storeName, store] of Object.entries(stores)) { previousStates[storeName] = JSON.parse(JSON.stringify(store.$state)); isApplyingBackendPatch[storeName] = false; diff --git a/web/src/composables/useTradingViewDatafeed.ts b/web/src/composables/useTradingViewDatafeed.ts new file mode 100644 index 0000000..d4e25da --- /dev/null +++ b/web/src/composables/useTradingViewDatafeed.ts @@ -0,0 +1,289 @@ +/** + * TradingView Datafeed implementation using WebSocket backend + * + * This adapter translates TradingView's Datafeed API calls into + * WebSocket messages that match the backend protocol defined in + * backend/src/datasource/websocket_protocol.py + * + * Uses the shared WebSocket connection for all communication. + */ + +import { wsManager, type MessageHandler } from './useWebSocket' +import type { + IBasicDataFeed, + DatafeedConfiguration, + LibrarySymbolInfo, + Bar, + SearchSymbolResultItem, + HistoryMetadata +} from '../types/tradingview' + +interface PendingRequest { + resolve: (value: any) => void + reject: (reason: any) => void + timeout: number +} + +interface Subscription { + symbolInfo: LibrarySymbolInfo + resolution: string + onTick: (bar: Bar) => void + subscriptionId: string +} + +export class WebSocketDatafeed implements IBasicDataFeed { + private pendingRequests: Map = new Map() + private subscriptions: Map = new Map() + private requestTimeout = 10000 // 10 seconds + private configuration: DatafeedConfiguration | null = null + private messageHandler: MessageHandler + + constructor() { + // Use the shared WebSocket connection (managed by App.vue authentication) + // Register message handler for datafeed messages + this.messageHandler = this.handleMessage.bind(this) + wsManager.addHandler(this.messageHandler) + } + + private generateRequestId(): string { + return `tv_${Date.now()}_${Math.random().toString(36).substring(7)}` + } + + private sendRequest(message: any): Promise { + const requestId = message.request_id || this.generateRequestId() + message.request_id = requestId + + return new Promise((resolve, reject) => { + const timeout = window.setTimeout(() => { + this.pendingRequests.delete(requestId) + reject(new Error('Request timeout')) + }, this.requestTimeout) + + this.pendingRequests.set(requestId, { resolve, reject, timeout }) + wsManager.send(message) + }) + } + + private handleMessage(message: any): void { + console.log('[TradingView Datafeed] Received message:', message) + + // Handle responses to pending requests + if (message.request_id && this.pendingRequests.has(message.request_id)) { + console.log('[TradingView Datafeed] Found pending request for:', message.request_id) + const pending = this.pendingRequests.get(message.request_id)! + this.pendingRequests.delete(message.request_id) + clearTimeout(pending.timeout) + + if (message.type === 'error') { + console.log('[TradingView Datafeed] Resolving with error:', message.error_message) + pending.reject(new Error(message.error_message || 'Unknown error')) + } else { + console.log('[TradingView Datafeed] Resolving with response') + pending.resolve(message) + } + } else if (message.request_id) { + console.log('[TradingView Datafeed] No pending request found for:', message.request_id) + } + + // Handle real-time bar updates + if (message.type === 'bar_update') { + const subscription = this.subscriptions.get(message.subscription_id) + if (subscription && message.bar) { + const bar: Bar = { + time: message.bar.time * 1000, // Convert to milliseconds + open: parseFloat(message.bar.data.open), + high: parseFloat(message.bar.data.high), + low: parseFloat(message.bar.data.low), + close: parseFloat(message.bar.data.close), + volume: parseFloat(message.bar.data.volume) + } + subscription.onTick(bar) + } + } + } + + onReady(callback: (configuration: DatafeedConfiguration) => void): void { + // TradingView requires callback to be called asynchronously + if (this.configuration) { + setTimeout(() => callback(this.configuration!), 0) + return + } + + this.sendRequest({ + type: 'get_config' + }) + .then((response) => { + this.configuration = response.config + setTimeout(() => callback(this.configuration!), 0) + }) + .catch((error) => { + console.error('Failed to get datafeed configuration:', error) + // Provide default configuration + const defaultConfig: DatafeedConfiguration = { + supported_resolutions: ['1', '5', '15', '30', '60', '240', '1D', '1W'], + supports_search: true, + supports_group_request: false, + supports_marks: false, + supports_timescale_marks: false, + supports_time: false + } + this.configuration = defaultConfig + setTimeout(() => callback(defaultConfig), 0) + }) + } + + searchSymbols( + userInput: string, + exchange: string, + symbolType: string, + onResult: (items: SearchSymbolResultItem[]) => void + ): void { + this.sendRequest({ + type: 'search_symbols', + query: userInput, + exchange: exchange || undefined, + symbol_type: symbolType || undefined, + limit: 30 + }) + .then((response) => { + onResult(response.results || []) + }) + .catch((error) => { + console.error('Symbol search failed:', error) + onResult([]) + }) + } + + resolveSymbol( + symbolName: string, + onResolve: (symbolInfo: LibrarySymbolInfo) => void, + onError: (reason: string) => void + ): void { + this.sendRequest({ + type: 'resolve_symbol', + symbol: symbolName + }) + .then((response) => { + if (response.symbol_info) { + console.log('[TradingView Datafeed] Resolved symbol info:', response.symbol_info) + onResolve(response.symbol_info) + } else { + onError('Symbol not found') + } + }) + .catch((error) => { + console.error('Failed to resolve symbol:', error) + onError(error instanceof Error ? error.message : 'Unknown error') + }) + } + + getBars( + symbolInfo: LibrarySymbolInfo, + resolution: string, + periodParams: { + from: number + to: number + firstDataRequest: boolean + countBack?: number + }, + onResult: (bars: Bar[], meta: HistoryMetadata) => void, + onError: (reason: string) => void + ): void { + this.sendRequest({ + type: 'get_bars', + symbol: symbolInfo.ticker || symbolInfo.name, + resolution: resolution, + from_time: periodParams.from, + to_time: periodParams.to, + countback: periodParams.countBack + }) + .then((response) => { + if (response.history) { + const bars: Bar[] = (response.history.bars || []).map((bar: any) => ({ + time: bar.time * 1000, // Convert to milliseconds + open: parseFloat(bar.data.open), + high: parseFloat(bar.data.high), + low: parseFloat(bar.data.low), + close: parseFloat(bar.data.close), + volume: parseFloat(bar.data.volume) + })) + + const meta: HistoryMetadata = { + noData: bars.length === 0, + nextTime: response.history.next_time + } + + onResult(bars, meta) + } else { + onResult([], { noData: true }) + } + }) + .catch((error) => { + console.error('Failed to get bars:', error) + onError(error instanceof Error ? error.message : 'Unknown error') + }) + } + + subscribeBars( + symbolInfo: LibrarySymbolInfo, + resolution: string, + onTick: (bar: Bar) => void, + listenerGuid: string, + onResetCacheNeededCallback: () => void + ): void { + this.sendRequest({ + type: 'subscribe_bars', + symbol: symbolInfo.ticker || symbolInfo.name, + resolution: resolution, + subscription_id: listenerGuid + }) + .then((response) => { + if (response.success) { + this.subscriptions.set(listenerGuid, { + symbolInfo, + resolution, + onTick, + subscriptionId: listenerGuid + }) + } + }) + .catch((error) => { + console.error('Failed to subscribe to bars:', error) + }) + } + + unsubscribeBars(listenerGuid: string): void { + this.sendRequest({ + type: 'unsubscribe_bars', + subscription_id: listenerGuid + }) + .then(() => { + this.subscriptions.delete(listenerGuid) + }) + .catch((error) => { + console.error('Failed to unsubscribe from bars:', error) + }) + } + + destroy(): void { + // Clean up all subscriptions + for (const [guid] of this.subscriptions) { + this.unsubscribeBars(guid) + } + this.subscriptions.clear() + + // Clear pending requests + for (const [requestId, pending] of this.pendingRequests) { + clearTimeout(pending.timeout) + pending.reject(new Error('Datafeed destroyed')) + } + this.pendingRequests.clear() + + // Remove message handler from shared WebSocket + wsManager.removeHandler(this.messageHandler) + } +} + +export function createTradingViewDatafeed(): IBasicDataFeed { + return new WebSocketDatafeed() +} diff --git a/web/src/composables/useWebSocket.ts b/web/src/composables/useWebSocket.ts new file mode 100644 index 0000000..56c2fe9 --- /dev/null +++ b/web/src/composables/useWebSocket.ts @@ -0,0 +1,181 @@ +import { ref } from 'vue' +import type { AuthMessage, AuthResponseMessage } from '../types/sync' + +export interface WebSocketMessage { + type: string + [key: string]: any +} + +export type MessageHandler = (message: WebSocketMessage) => void + +export interface AuthResult { + success: boolean + needsConfirmation: boolean + message: string +} + +class WebSocketManager { + private ws: WebSocket | null = null + private handlers: Set = new Set() + private reconnectTimeout: number | null = null + public isConnected = ref(false) + public isAuthenticated = ref(false) + private authResolve: ((result: AuthResult) => void) | null = null + private shouldReconnect = false // Disable auto-reconnect (user must re-authenticate) + private storedPassword: string | null = null + private storedConfirmPassword: string | undefined = undefined + + async connect( + password: string, + confirmPassword?: string, + changeToPassword?: string, + confirmNewPassword?: string + ): Promise { + // Store password for potential reconnection + this.storedPassword = password + this.storedConfirmPassword = confirmPassword + + // Close existing connection if any + if (this.ws) { + this.shouldReconnect = false + this.ws.close() + this.ws = null + } + + return new Promise((resolve, reject) => { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const host = window.location.hostname + this.ws = new WebSocket(`${protocol}//${host}:8080/ws`) + + this.authResolve = resolve + + this.ws.onopen = () => { + console.log('WebSocket connected, sending auth...') + + // Send authentication message + const authMsg: AuthMessage = { + type: 'auth', + password, + confirm_password: confirmPassword, + change_to_password: changeToPassword, + confirm_new_password: confirmNewPassword + } + this.ws?.send(JSON.stringify(authMsg)) + } + + this.ws.onmessage = (event) => { + try { + const message = JSON.parse(event.data) + + // Handle auth response + if (message.type === 'auth_response') { + const authResponse = message as AuthResponseMessage + console.log('Auth response:', authResponse) + + if (authResponse.success) { + this.isConnected.value = true + this.isAuthenticated.value = true + this.shouldReconnect = true + console.log('Authentication successful') + } else { + this.isAuthenticated.value = false + console.warn('Authentication failed:', authResponse.message) + } + + if (this.authResolve) { + this.authResolve({ + success: authResponse.success, + needsConfirmation: authResponse.needs_confirmation || false, + message: authResponse.message + }) + this.authResolve = null + } + + // Don't return, let other handlers process this message too + } + + // Pass to all handlers + this.handlers.forEach(handler => handler(message)) + } catch (err) { + console.error('Error parsing WebSocket message:', err) + } + } + + this.ws.onerror = (error) => { + console.error('WebSocket error:', error) + this.isConnected.value = false + this.isAuthenticated.value = false + + if (this.authResolve) { + this.authResolve({ + success: false, + needsConfirmation: false, + message: 'Connection error' + }) + this.authResolve = null + } + } + + this.ws.onclose = (event) => { + this.isConnected.value = false + this.isAuthenticated.value = false + console.log('WebSocket disconnected:', event.code, event.reason) + + if (this.authResolve) { + this.authResolve({ + success: false, + needsConfirmation: false, + message: event.reason || 'Connection closed' + }) + this.authResolve = null + } + + // No auto-reconnect - user must re-authenticate manually + // This prevents reconnection attempts without proper password + } + + // Timeout for auth + setTimeout(() => { + if (this.authResolve) { + this.authResolve({ + success: false, + needsConfirmation: false, + message: 'Authentication timeout' + }) + this.authResolve = null + this.ws?.close() + } + }, 30000) + }) + } + + addHandler(handler: MessageHandler) { + this.handlers.add(handler) + } + + removeHandler(handler: MessageHandler) { + this.handlers.delete(handler) + } + + send(message: WebSocketMessage) { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(message)) + } + } + + disconnect() { + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout) + } + if (this.ws) { + this.ws.close() + this.ws = null + } + } + + getWebSocket() { + return this.ws + } +} + +export const wsManager = new WebSocketManager() diff --git a/web/src/stores/orders.ts b/web/src/stores/orders.ts new file mode 100644 index 0000000..e770747 --- /dev/null +++ b/web/src/stores/orders.ts @@ -0,0 +1,24 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export interface SwapOrder { + tokenIn: string + tokenOut: string + route: { + exchange: string + fee: string + } + amount: string + minFillAmount: string + amountIsInput: boolean + outputDirectlyToOwner: boolean + inverted: boolean + conditionalOrder: string + tranches: any[] +} + +export const useOrderStore = defineStore('OrderStore', () => { + const orders = ref([]) + + return { orders } +}) diff --git a/web/src/types/sync.ts b/web/src/types/sync.ts index 3193fb0..568981c 100644 --- a/web/src/types/sync.ts +++ b/web/src/types/sync.ts @@ -1,3 +1,19 @@ +export interface AuthMessage { + type: 'auth'; + password: string; + confirm_password?: string; + change_to_password?: string; + confirm_new_password?: string; +} + +export interface AuthResponseMessage { + type: 'auth_response'; + success: boolean; + needs_confirmation?: boolean; + password_changed?: boolean; + message: string; +} + export interface SnapshotMessage { type: 'snapshot'; store: string; @@ -17,5 +33,5 @@ export interface HelloMessage { seqs: Record; } -export type BackendMessage = SnapshotMessage | PatchMessage; -export type FrontendMessage = HelloMessage | PatchMessage; +export type BackendMessage = SnapshotMessage | PatchMessage | AuthResponseMessage; +export type FrontendMessage = AuthMessage | HelloMessage | PatchMessage; diff --git a/web/src/types/tradingview.ts b/web/src/types/tradingview.ts new file mode 100644 index 0000000..c23a57b --- /dev/null +++ b/web/src/types/tradingview.ts @@ -0,0 +1,136 @@ +/** + * TradingView Charting Library type definitions + * Based on TradingView DataFeed API + */ + +export interface LibrarySymbolInfo { + name: string + ticker?: string + description: string + type: string + session: string + timezone: string + exchange: string + minmov: number + pricescale: number + has_intraday: boolean + has_daily: boolean + has_weekly_and_monthly: boolean + supported_resolutions: string[] + volume_precision?: number + data_status?: 'streaming' | 'endofday' | 'pulsed' | 'delayed_streaming' + [key: string]: any +} + +export interface Bar { + time: number // Unix timestamp in milliseconds + open: number + high: number + low: number + close: number + volume?: number +} + +export interface HistoryMetadata { + noData: boolean + nextTime?: number +} + +export interface SearchSymbolResultItem { + symbol: string + full_name: string + description: string + exchange: string + ticker?: string + type: string +} + +export interface DatafeedConfiguration { + supported_resolutions: string[] + supports_search?: boolean + supports_group_request?: boolean + supports_marks?: boolean + supports_timescale_marks?: boolean + supports_time?: boolean + exchanges?: Array<{ value: string; name: string; desc: string }> + symbols_types?: Array<{ name: string; value: string }> +} + +export interface IBasicDataFeed { + onReady(callback: (configuration: DatafeedConfiguration) => void): void + + searchSymbols( + userInput: string, + exchange: string, + symbolType: string, + onResult: (items: SearchSymbolResultItem[]) => void + ): void + + resolveSymbol( + symbolName: string, + onResolve: (symbolInfo: LibrarySymbolInfo) => void, + onError: (reason: string) => void + ): void + + getBars( + symbolInfo: LibrarySymbolInfo, + resolution: string, + periodParams: { + from: number + to: number + firstDataRequest: boolean + countBack?: number + }, + onResult: (bars: Bar[], meta: HistoryMetadata) => void, + onError: (reason: string) => void + ): void + + subscribeBars( + symbolInfo: LibrarySymbolInfo, + resolution: string, + onTick: (bar: Bar) => void, + listenerGuid: string, + onResetCacheNeededCallback: () => void + ): void + + unsubscribeBars(listenerGuid: string): void +} + +export interface ChartingLibraryWidgetOptions { + symbol: string + datafeed: IBasicDataFeed + interval: string + container: string | HTMLElement + library_path: string + locale?: string + disabled_features?: string[] + enabled_features?: string[] + charts_storage_url?: string + charts_storage_api_version?: string + client_id?: string + user_id?: string + fullscreen?: boolean + autosize?: boolean + theme?: 'Light' | 'Dark' + timezone?: string + debug?: boolean + [key: string]: any +} + +export interface IChartingLibraryWidget { + onChartReady(callback: () => void): void + headerReady(): Promise + remove(): void + activeChart(): any + [key: string]: any +} + +// Global TradingView namespace +declare global { + interface Window { + TradingView: { + widget: new (options: ChartingLibraryWidgetOptions) => IChartingLibraryWidget + version: () => string + } + } +}