This commit is contained in:
2026-03-02 01:36:14 -04:00
parent d907c5765e
commit 311df9aeda
21 changed files with 2772 additions and 33 deletions

3
.gitignore vendored
View File

@@ -1,3 +1,6 @@
/backend/data
/backend/uploads/
# Environment variables # Environment variables
.env .env
.env.local .env.local

View File

@@ -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!")

View File

@@ -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.

View File

@@ -30,3 +30,7 @@ aiofiles>=24.0.0
# Environment configuration # Environment configuration
python-dotenv>=1.0.0 python-dotenv>=1.0.0
# Secrets management
cryptography>=42.0.0
argon2-cffi>=23.0.0

View File

@@ -572,8 +572,8 @@ async def analyze_chart_data(python_script: str, countback: Optional[int] = None
plot_urls = [] plot_urls = []
# Determine uploads directory (relative to this file) # Determine uploads directory (relative to this file)
uploads_dir = Path(__file__).parent.parent.parent / "uploads" uploads_dir = Path(__file__).parent.parent.parent / "data" / "uploads"
uploads_dir.mkdir(exist_ok=True) uploads_dir.mkdir(parents=True, exist_ok=True)
try: try:
with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture): with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture):

View File

@@ -14,7 +14,7 @@ from pydantic import BaseModel
import uuid import uuid
import shutil import shutil
from sync.protocol import HelloMessage, PatchMessage from sync.protocol import HelloMessage, PatchMessage, AuthMessage, AuthResponseMessage
from sync.registry import SyncRegistry from sync.registry import SyncRegistry
from gateway.hub import Gateway from gateway.hub import Gateway
from gateway.channels.websocket import WebSocketChannel from gateway.channels.websocket import WebSocketChannel
@@ -26,6 +26,7 @@ from schema.chart_state import ChartState
from datasource.registry import DataSourceRegistry from datasource.registry import DataSourceRegistry
from datasource.subscription_manager import SubscriptionManager from datasource.subscription_manager import SubscriptionManager
from datasource.websocket_handler import DatafeedWebSocketHandler from datasource.websocket_handler import DatafeedWebSocketHandler
from secrets_manager import SecretsStore, InvalidMasterPassword
# Configure logging # Configure logging
logging.basicConfig( logging.basicConfig(
@@ -52,6 +53,9 @@ agent_executor = None
datasource_registry = DataSourceRegistry() datasource_registry = DataSourceRegistry()
subscription_manager = SubscriptionManager() subscription_manager = SubscriptionManager()
# Global secrets store
secrets_store = SecretsStore()
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): 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.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") logger.info("To use real exchange data, install ccxt: pip install ccxt>=4.0.0")
# Get API keys from environment # 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") 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: if not anthropic_api_key:
logger.error("ANTHROPIC_API_KEY not found in environment!") logger.error("ANTHROPIC_API_KEY not found in environment!")
@@ -117,8 +132,8 @@ async def lifespan(app: FastAPI):
app = FastAPI(lifespan=lifespan) app = FastAPI(lifespan=lifespan)
# Create uploads directory # Create uploads directory
UPLOAD_DIR = Path(__file__).parent.parent / "uploads" UPLOAD_DIR = Path(__file__).parent.parent / "data" / "uploads"
UPLOAD_DIR.mkdir(exist_ok=True) UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
# Mount static files for serving uploads # Mount static files for serving uploads
app.mount("/uploads", StaticFiles(directory=str(UPLOAD_DIR)), name="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") @app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket): async def websocket_endpoint(websocket: WebSocket):
await websocket.accept() 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 # Helper function to send responses
async def send_response(response): async def send_response(response):
@@ -158,6 +165,168 @@ async def websocket_endpoint(websocket: WebSocket):
except Exception as e: except Exception as e:
logger.error(f"Error sending response: {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: try:
while True: while True:
data = await websocket.receive_text() data = await websocket.receive_text()

View File

@@ -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 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): class SnapshotMessage(BaseModel):
type: Literal["snapshot"] = "snapshot" type: Literal["snapshot"] = "snapshot"
store: str store: str
@@ -20,7 +36,7 @@ class HelloMessage(BaseModel):
seqs: Dict[str, int] seqs: Dict[str, int]
# Union type for all messages from backend to frontend # 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 # Union type for all messages from frontend to backend
FrontendMessage = Union[HelloMessage, PatchMessage] FrontendMessage = Union[AuthMessage, HelloMessage, PatchMessage]

View File

@@ -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())

View File

@@ -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())

View File

@@ -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())

192
doc/auth.md Normal file
View File

@@ -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)

122
web/src/App.vue Normal file
View File

@@ -0,0 +1,122 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import Splitter from 'primevue/splitter'
import SplitterPanel from 'primevue/splitterpanel'
import ChartView from './components/ChartView.vue'
import ChatPanel from './components/ChatPanel.vue'
import LoginScreen from './components/LoginScreen.vue'
import { useOrderStore } from './stores/orders'
import { useChartStore } from './stores/chart'
import { useStateSync } from './composables/useStateSync'
import { wsManager } from './composables/useWebSocket'
const isAuthenticated = ref(false)
const needsConfirmation = ref(false)
const authError = ref<string>()
let stateSyncCleanup: (() => void) | null = null
// Check if we need password confirmation on first load
onMounted(async () => {
// Check if secrets store is initialized by trying to fetch a status endpoint
// For now, just default to false (user will see login screen)
needsConfirmation.value = false
})
const handleAuthenticate = async (
password: string,
confirmPassword?: string,
newPassword?: string,
confirmNewPassword?: string
) => {
authError.value = undefined
try {
const result = await wsManager.connect(password, confirmPassword, newPassword, confirmNewPassword)
if (result.success) {
isAuthenticated.value = true
// Initialize state sync after successful authentication
const orderStore = useOrderStore()
const chartStore = useChartStore()
const stateSync = useStateSync({
OrderStore: orderStore,
ChartStore: chartStore
})
stateSyncCleanup = stateSync.cleanup
} else {
authError.value = result.message
// If server says we need confirmation, update the flag
if (result.needsConfirmation) {
needsConfirmation.value = true
}
}
} catch (err) {
authError.value = 'Connection failed'
console.error('Authentication error:', err)
}
}
onBeforeUnmount(() => {
if (stateSyncCleanup) {
stateSyncCleanup()
}
wsManager.disconnect()
})
</script>
<template>
<div class="app-container dark">
<LoginScreen
v-if="!isAuthenticated"
:needs-confirmation="needsConfirmation"
:error-message="authError"
@authenticate="handleAuthenticate"
/>
<Splitter v-else class="main-splitter">
<SplitterPanel :size="62" :minSize="40" class="chart-panel">
<ChartView />
</SplitterPanel>
<SplitterPanel :size="38" :minSize="20" class="chat-panel">
<ChatPanel />
</SplitterPanel>
</Splitter>
</div>
</template>
<style scoped>
.app-container {
width: 100vw !important;
height: 100vh !important;
overflow: hidden;
background: var(--p-surface-0);
}
.main-splitter {
height: 100vh !important;
}
.main-splitter :deep(.p-splitter-gutter) {
background: var(--p-surface-100);
}
.main-splitter :deep(.p-splitter-gutter-handle) {
background: var(--p-primary-color);
}
.chart-panel,
.chat-panel {
height: 100% !important;
display: flex;
flex-direction: column;
overflow: hidden;
}
.chart-panel :deep(.p-splitter-panel-content),
.chat-panel :deep(.p-splitter-panel-content) {
height: 100%;
display: flex;
flex-direction: column;
}
</style>

View File

@@ -0,0 +1,217 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import Card from 'primevue/card'
import { createTradingViewDatafeed } from '../composables/useTradingViewDatafeed'
import { useChartStore } from '../stores/chart'
import type { IChartingLibraryWidget } from '../types/tradingview'
const chartContainer = ref<HTMLDivElement | null>(null)
const chartStore = useChartStore()
let tvWidget: IChartingLibraryWidget | null = null
let datafeed: any = null
let isUpdatingFromChart = false // Flag to prevent circular updates
onMounted(() => {
if (!chartContainer.value) return
// Wait for TradingView library to load
const initChart = () => {
if (!window.TradingView) {
setTimeout(initChart, 100)
return
}
try {
datafeed = createTradingViewDatafeed()
tvWidget = new window.TradingView.widget({
symbol: chartStore.chart_state.symbol, // Use symbol from store
datafeed: datafeed,
interval: chartStore.chart_state.interval as any,
container: chartContainer.value!,
library_path: '/charting_library/',
locale: 'en',
disabled_features: [
'use_localstorage_for_settings',
'header_symbol_search',
'symbol_search_hot_key'
],
enabled_features: ['study_templates'],
fullscreen: false,
autosize: true,
theme: 'Dark',
timezone: 'Etc/UTC'
})
tvWidget.onChartReady(() => {
console.log('TradingView chart ready')
setupChartListeners()
setupStoreWatchers()
// Initialize visible range on chart load
initializeVisibleRange()
})
} catch (error) {
console.error('Failed to initialize TradingView widget:', error)
}
}
initChart()
})
function initializeVisibleRange() {
if (!tvWidget) return
const chart = tvWidget.activeChart()
const timeScale = chart.getVisibleRange()
if (timeScale) {
const startTime = Math.floor(timeScale.from)
const endTime = Math.floor(timeScale.to)
console.log('[ChartView] Initial visible range:', {
from: new Date(startTime * 1000).toISOString(),
to: new Date(endTime * 1000).toISOString()
})
chartStore.chart_state.start_time = startTime
chartStore.chart_state.end_time = endTime
}
}
function setupChartListeners() {
if (!tvWidget) return
const chart = tvWidget.activeChart()
// Listen for symbol changes
chart.onSymbolChanged().subscribe(null, () => {
const symbolInfo = chart.symbolExt()
if (symbolInfo && symbolInfo.ticker) {
console.log('[ChartView] Symbol changed to:', symbolInfo.ticker)
isUpdatingFromChart = true
chartStore.chart_state.symbol = symbolInfo.ticker
isUpdatingFromChart = false
}
})
// Listen for interval changes
chart.onIntervalChanged().subscribe(null, (interval: string) => {
console.log('[ChartView] Interval changed to:', interval)
isUpdatingFromChart = true
chartStore.chart_state.interval = interval
isUpdatingFromChart = false
})
// Listen for visible range changes (when user pans/zooms)
chart.onVisibleRangeChanged().subscribe(null, () => {
const timeScale = chart.getVisibleRange()
if (timeScale) {
// Convert from seconds to seconds (TradingView uses seconds for visible range)
const startTime = Math.floor(timeScale.from)
const endTime = Math.floor(timeScale.to)
console.log('[ChartView] Visible range changed:', {
from: new Date(startTime * 1000).toISOString(),
to: new Date(endTime * 1000).toISOString()
})
isUpdatingFromChart = true
chartStore.chart_state.start_time = startTime
chartStore.chart_state.end_time = endTime
isUpdatingFromChart = false
}
})
}
function setupStoreWatchers() {
if (!tvWidget) return
const chart = tvWidget.activeChart()
// Watch for external changes to symbol (e.g., from backend/agent)
watch(
() => chartStore.chart_state.symbol,
(newSymbol) => {
if (isUpdatingFromChart) return // Ignore updates that came from the chart itself
console.log('[ChartView] Store symbol changed externally to:', newSymbol)
const currentSymbol = chart.symbolExt()
if (currentSymbol && currentSymbol.ticker !== newSymbol) {
chart.setSymbol(newSymbol, () => {
console.log('[ChartView] Chart symbol updated to:', newSymbol)
})
}
}
)
// Watch for external changes to interval
watch(
() => chartStore.chart_state.interval,
(newInterval) => {
if (isUpdatingFromChart) return
console.log('[ChartView] Store interval changed externally to:', newInterval)
if (chart.resolution() !== newInterval) {
chart.setResolution(newInterval, () => {
console.log('[ChartView] Chart interval updated to:', newInterval)
})
}
}
)
// Note: Visible range is typically only set by user interaction,
// but we could add a watcher here if we want the backend to be able
// to change the visible range programmatically
}
onBeforeUnmount(() => {
if (tvWidget) {
tvWidget.remove()
tvWidget = null
}
if (datafeed && typeof datafeed.destroy === 'function') {
datafeed.destroy()
datafeed = null
}
})
</script>
<template>
<Card class="chart-card">
<template #content>
<div ref="chartContainer" class="chart-container"></div>
</template>
</Card>
</template>
<style scoped>
.chart-card {
height: 100% !important;
display: flex;
flex-direction: column;
overflow: hidden;
border: none;
background: var(--p-surface-0);
}
.chart-card :deep(.p-card-body) {
flex: 1;
display: flex;
flex-direction: column;
padding: 0;
overflow: hidden;
}
.chart-card :deep(.p-card-content) {
flex: 1;
display: flex;
overflow: hidden;
padding: 0;
}
.chart-container {
flex: 1;
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,309 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { register } from 'vue-advanced-chat'
import Badge from 'primevue/badge'
import { wsManager } from '../composables/useWebSocket'
import type { WebSocketMessage } from '../composables/useWebSocket'
register()
const SESSION_ID = 'default'
const CURRENT_USER_ID = 'user-123'
const AGENT_ID = 'agent'
const BACKEND_URL = 'http://localhost:8080'
// Chat state
const messages = ref<any[]>([])
const messagesLoaded = ref(false)
const isConnected = wsManager.isConnected
// Reactive rooms that update based on WebSocket connection
const rooms = computed(() => [{
roomId: SESSION_ID,
roomName: 'AI Agent',
avatar: null,
users: [
{ _id: CURRENT_USER_ID, username: 'You' },
{ _id: AGENT_ID, username: 'AI Agent', status: { state: isConnected.value ? 'online' : 'offline' } }
],
unreadCount: 0
}])
// Streaming state
let currentStreamingMessageId: string | null = null
let streamingBuffer = ''
// Generate message ID
const generateMessageId = () => `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
// Handle WebSocket messages
const handleMessage = (data: WebSocketMessage) => {
if (data.type === 'agent_chunk') {
const timestamp = new Date().toTimeString().split(' ')[0].slice(0, 5)
if (!currentStreamingMessageId) {
// Start new streaming message
currentStreamingMessageId = generateMessageId()
streamingBuffer = data.content
messages.value = [...messages.value, {
_id: currentStreamingMessageId,
content: streamingBuffer,
senderId: AGENT_ID,
timestamp: timestamp,
date: new Date().toLocaleDateString(),
saved: false,
distributed: false,
seen: false
}]
} else {
// Update existing streaming message
streamingBuffer += data.content
const msgIndex = messages.value.findIndex(m => m._id === currentStreamingMessageId)
if (msgIndex !== -1) {
messages.value[msgIndex] = {
...messages.value[msgIndex],
content: streamingBuffer
}
messages.value = [...messages.value]
}
}
if (data.done) {
// Mark message as complete
const msgIndex = messages.value.findIndex(m => m._id === currentStreamingMessageId)
if (msgIndex !== -1) {
messages.value[msgIndex] = {
...messages.value[msgIndex],
saved: true,
distributed: true,
seen: true
}
messages.value = [...messages.value]
}
currentStreamingMessageId = null
streamingBuffer = ''
}
}
}
// Send message handler
const sendMessage = async (event: any) => {
// Extract data from CustomEvent.detail[0]
const data = event.detail?.[0] || event
const content = data.content || ''
const files = data.files
const roomId = data.roomId
const timestamp = new Date().toTimeString().split(' ')[0].slice(0, 5)
const messageId = generateMessageId()
// Process files
let attachments: string[] = []
let messageFiles: any[] = []
if (files && files.length > 0) {
// Upload files to backend
for (const file of files) {
try {
const formData = new FormData()
// Convert blob URL to actual file if needed
const response = await fetch(file.url)
const blob = await response.blob()
const actualFile = new File([blob], file.name, { type: file.type })
formData.append('file', actualFile)
const uploadResponse = await fetch(`${BACKEND_URL}/api/upload`, {
method: 'POST',
body: formData
})
if (!uploadResponse.ok) {
throw new Error('Upload failed')
}
const uploadData = await uploadResponse.json()
const fileUrl = `${BACKEND_URL}${uploadData.url}`
attachments.push(fileUrl)
messageFiles.push({
name: file.name,
size: uploadData.size,
type: file.extension || file.type,
url: fileUrl,
preview: file.url // Keep the blob URL for preview
})
} catch (error) {
console.error('File upload error:', error)
}
}
}
// Add user message to UI
const userMessage = {
_id: messageId,
content: content,
senderId: CURRENT_USER_ID,
timestamp: timestamp,
date: new Date().toLocaleDateString(),
saved: true,
distributed: false,
seen: false,
files: messageFiles
}
messages.value = [...messages.value, userMessage]
// Send to backend via WebSocket
const wsMessage = {
type: 'agent_user_message',
session_id: roomId || SESSION_ID,
content: content,
attachments: attachments
}
wsManager.send(wsMessage)
// Mark as distributed
setTimeout(() => {
const msgIndex = messages.value.findIndex(m => m._id === messageId)
if (msgIndex !== -1) {
messages.value[msgIndex] = {
...messages.value[msgIndex],
distributed: true,
seen: true
}
messages.value = [...messages.value]
}
}, 100)
}
// Fetch messages handler (for pagination, not needed for simple chat)
const fetchMessages = ({ room, options }: any) => {
messagesLoaded.value = true
}
// Open file handler
const openFile = ({ file }: any) => {
window.open(file.url, '_blank')
}
// Theme configuration for dark mode
const chatTheme = 'light'
// Styles to match PrimeVue theme
const chatStyles = computed(() => JSON.stringify({
general: {
color: 'var(--p-surface-900)',
colorSpinner: 'var(--p-primary-color)',
borderStyle: '1px solid var(--p-surface-200)'
},
container: {
background: 'var(--p-surface-0)'
},
header: {
background: 'var(--p-surface-50)',
colorRoomName: 'var(--p-surface-900)',
colorRoomInfo: 'var(--p-surface-700)'
},
footer: {
background: 'var(--p-surface-50)',
borderStyleInput: '1px solid var(--p-surface-300)',
backgroundInput: 'var(--p-surface-200)',
colorInput: 'var(--p-surface-900)',
colorPlaceholder: 'var(--p-surface-400)',
colorIcons: 'var(--p-surface-400)'
},
content: {
background: 'var(--p-surface-0)'
},
message: {
background: 'var(--p-surface-100)',
backgroundMe: 'var(--p-primary-color)',
color: 'var(--p-surface-900)',
colorMe: 'var(--p-primary-contrast-color)'
}
}))
onMounted(() => {
wsManager.addHandler(handleMessage)
// Mark messages as loaded after initialization
messagesLoaded.value = true
})
onUnmounted(() => {
wsManager.removeHandler(handleMessage)
})
</script>
<template>
<div class="chat-container">
<!--
<div class="chat-header-custom">
<span class="chat-title">AI Agent Chat</span>
<Badge
:value="isConnected ? 'Connected' : 'Disconnected'"
:severity="isConnected ? 'success' : 'danger'"
/>
</div>
-->
<vue-advanced-chat
height="calc(100vh - 60px)"
:current-user-id="CURRENT_USER_ID"
:rooms="JSON.stringify(rooms)"
:messages="JSON.stringify(messages)"
:messages-loaded="messagesLoaded"
:room-id="SESSION_ID"
:theme="chatTheme"
:styles="chatStyles"
:single-room="true"
:rooms-list-opened="false"
:show-add-room="false"
:show-audio="false"
:show-files="true"
:show-emojis="true"
:show-reaction-emojis="false"
:accepted-files="'image/*,video/*,application/pdf'"
@send-message="sendMessage"
@fetch-messages="fetchMessages"
@open-file="openFile"
/>
</div>
</template>
<style scoped>
.chat-container {
height: 100% !important;
display: flex;
flex-direction: column;
background: var(--p-surface-0);
overflow: hidden;
}
.chat-container :deep(.vac-container) {
height: 100% !important;
}
.chat-container :deep(.vac-text-message) {
white-space: pre-wrap;
}
.chat-header-custom {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
background: var(--p-surface-50);
border-bottom: 1px solid var(--p-surface-200);
flex-shrink: 0;
}
.chat-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--p-surface-900);
}
</style>

View File

@@ -0,0 +1,292 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import Card from 'primevue/card'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import Button from 'primevue/button'
import Message from 'primevue/message'
const props = defineProps<{
needsConfirmation: boolean
errorMessage?: string
}>()
const emit = defineEmits<{
authenticate: [password: string, confirmPassword?: string, newPassword?: string, confirmNewPassword?: string]
}>()
const password = ref('')
const confirmPassword = ref('')
const newPassword = ref('')
const confirmNewPassword = ref('')
const isLoading = ref(false)
const isChangingPassword = ref(false)
const canSubmit = computed(() => {
if (!password.value || isLoading.value) return false
if (props.needsConfirmation && password.value !== confirmPassword.value) return false
if (isChangingPassword.value) {
if (!newPassword.value || !confirmNewPassword.value) return false
if (newPassword.value !== confirmNewPassword.value) return false
}
return true
})
const handleSubmit = () => {
if (!canSubmit.value) return
// Prevent double-submit
if (isLoading.value) return
isLoading.value = true
if (props.needsConfirmation) {
emit('authenticate', password.value, confirmPassword.value)
} else if (isChangingPassword.value) {
emit('authenticate', password.value, undefined, newPassword.value, confirmNewPassword.value)
} else {
emit('authenticate', password.value)
}
// Reset loading state after a timeout (parent will handle actual auth)
setTimeout(() => {
isLoading.value = false
}, 5000)
}
const handleKeypress = (event: KeyboardEvent) => {
if (event.key === 'Enter' && canSubmit.value) {
event.preventDefault() // Prevent form submission if this is in a form
handleSubmit()
}
}
const togglePasswordChange = () => {
isChangingPassword.value = !isChangingPassword.value
newPassword.value = ''
confirmNewPassword.value = ''
}
</script>
<template>
<div class="login-screen">
<div class="login-container">
<Card class="login-card">
<template #title>
<div class="login-title">
<i class="pi pi-lock" style="font-size: 2rem; margin-bottom: 0.5rem;"></i>
<h2>{{ needsConfirmation ? 'Welcome' : 'Authentication' }}</h2>
</div>
</template>
<template #content>
<div class="login-content">
<p v-if="needsConfirmation" class="welcome-message">
This is your first time connecting. Please create a master password to secure your workspace.
</p>
<p v-else-if="isChangingPassword" class="welcome-message">
Enter your current password and choose a new one.
</p>
<p v-else class="welcome-message">
Enter your master password to connect.
</p>
<Message v-if="errorMessage" severity="error" :closable="false">
{{ errorMessage }}
</Message>
<div class="form-field">
<label for="password">{{ isChangingPassword ? 'Current Password' : 'Password' }}</label>
<Password
id="password"
v-model="password"
:feedback="needsConfirmation"
toggleMask
placeholder="Enter password"
class="password-input"
@keypress="handleKeypress"
:disabled="isLoading"
autocomplete="current-password"
/>
</div>
<div v-if="needsConfirmation" class="form-field">
<label for="confirm-password">Confirm Password</label>
<Password
id="confirm-password"
v-model="confirmPassword"
:feedback="false"
toggleMask
placeholder="Confirm password"
class="password-input"
@keypress="handleKeypress"
:disabled="isLoading"
autocomplete="new-password"
/>
<small v-if="confirmPassword && password !== confirmPassword" class="p-error">
Passwords do not match
</small>
</div>
<!-- Password change fields -->
<div v-if="isChangingPassword" class="password-change-section">
<div class="form-field">
<label for="new-password">New Password</label>
<Password
id="new-password"
v-model="newPassword"
:feedback="true"
toggleMask
placeholder="Enter new password"
class="password-input"
@keypress="handleKeypress"
:disabled="isLoading"
autocomplete="new-password"
/>
</div>
<div class="form-field">
<label for="confirm-new-password">Confirm New Password</label>
<Password
id="confirm-new-password"
v-model="confirmNewPassword"
:feedback="false"
toggleMask
placeholder="Confirm new password"
class="password-input"
@keypress="handleKeypress"
:disabled="isLoading"
autocomplete="new-password"
/>
<small v-if="confirmNewPassword && newPassword !== confirmNewPassword" class="p-error">
Passwords do not match
</small>
</div>
</div>
<Button
:label="needsConfirmation ? 'Create & Connect' : isChangingPassword ? 'Change Password & Connect' : 'Connect'"
:icon="isChangingPassword ? 'pi pi-key' : 'pi pi-sign-in'"
@click="handleSubmit"
:disabled="!canSubmit"
:loading="isLoading"
class="connect-button"
/>
<!-- Toggle password change link -->
<div v-if="!needsConfirmation" class="change-password-link">
<a @click="togglePasswordChange" href="#">
{{ isChangingPassword ? 'Cancel password change' : 'Change password' }}
</a>
</div>
</div>
</template>
</Card>
</div>
</div>
</template>
<style scoped>
.login-screen {
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--p-surface-0) 0%, var(--p-surface-100) 100%);
}
.login-container {
width: 100%;
max-width: 450px;
padding: 2rem;
}
.login-card {
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
.login-title {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
color: var(--p-primary-color);
}
.login-title h2 {
margin: 0;
font-size: 1.75rem;
font-weight: 600;
}
.login-content {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.welcome-message {
text-align: center;
color: var(--p-text-secondary-color);
margin: 0;
line-height: 1.6;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-field label {
font-weight: 600;
color: var(--p-text-color);
}
.password-input {
width: 100%;
}
.password-input :deep(.p-inputtext) {
width: 100%;
}
.connect-button {
width: 100%;
padding: 0.75rem;
font-size: 1.1rem;
margin-top: 0.5rem;
}
.p-error {
color: var(--p-red-500);
font-size: 0.875rem;
}
.password-change-section {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--p-surface-border);
margin-top: 0.5rem;
}
.change-password-link {
text-align: center;
margin-top: -0.5rem;
}
.change-password-link a {
color: var(--p-primary-color);
text-decoration: none;
font-size: 0.9rem;
cursor: pointer;
transition: opacity 0.2s;
}
.change-password-link a:hover {
opacity: 0.8;
text-decoration: underline;
}
</style>

View File

@@ -102,22 +102,35 @@ export function useStateSync(stores: Record<string, Store>) {
sendJson(msg); 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 // Set up watchers for each store to send patches on changes
const unwatchFunctions: (() => void)[] = []; 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)) { for (const [storeName, store] of Object.entries(stores)) {
previousStates[storeName] = JSON.parse(JSON.stringify(store.$state)); previousStates[storeName] = JSON.parse(JSON.stringify(store.$state));
isApplyingBackendPatch[storeName] = false; isApplyingBackendPatch[storeName] = false;

View File

@@ -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<string, PendingRequest> = new Map()
private subscriptions: Map<string, Subscription> = 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<T>(message: any): Promise<T> {
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<any>({
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<any>({
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<any>({
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<any>({
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<any>({
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<any>({
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()
}

View File

@@ -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<MessageHandler> = 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<AuthResult> {
// 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()

24
web/src/stores/orders.ts Normal file
View File

@@ -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<SwapOrder[]>([])
return { orders }
})

View File

@@ -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 { export interface SnapshotMessage {
type: 'snapshot'; type: 'snapshot';
store: string; store: string;
@@ -17,5 +33,5 @@ export interface HelloMessage {
seqs: Record<string, number>; seqs: Record<string, number>;
} }
export type BackendMessage = SnapshotMessage | PatchMessage; export type BackendMessage = SnapshotMessage | PatchMessage | AuthResponseMessage;
export type FrontendMessage = HelloMessage | PatchMessage; export type FrontendMessage = AuthMessage | HelloMessage | PatchMessage;

View File

@@ -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<void>
remove(): void
activeChart(): any
[key: string]: any
}
// Global TradingView namespace
declare global {
interface Window {
TradingView: {
widget: new (options: ChartingLibraryWidgetOptions) => IChartingLibraryWidget
version: () => string
}
}
}