auth
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,6 @@
|
||||
/backend/data
|
||||
/backend/uploads/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
|
||||
97
backend/examples/ccxt_integration_example.py
Normal file
97
backend/examples/ccxt_integration_example.py
Normal 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!")
|
||||
174
backend/memory/system_prompt.md
Normal file
174
backend/memory/system_prompt.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
# 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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
199
backend/tests/datafeed_client_example.py
Normal file
199
backend/tests/datafeed_client_example.py
Normal 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())
|
||||
156
backend/tests/test_ccxt_datasource.py
Normal file
156
backend/tests/test_ccxt_datasource.py
Normal 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())
|
||||
130
backend/tests/test_datafeed_websocket.py
Normal file
130
backend/tests/test_datafeed_websocket.py
Normal 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
192
doc/auth.md
Normal 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
122
web/src/App.vue
Normal 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>
|
||||
217
web/src/components/ChartView.vue
Normal file
217
web/src/components/ChartView.vue
Normal 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>
|
||||
309
web/src/components/ChatPanel.vue
Normal file
309
web/src/components/ChatPanel.vue
Normal 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>
|
||||
292
web/src/components/LoginScreen.vue
Normal file
292
web/src/components/LoginScreen.vue
Normal 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>
|
||||
@@ -102,22 +102,35 @@ export function useStateSync(stores: Record<string, Store>) {
|
||||
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;
|
||||
|
||||
289
web/src/composables/useTradingViewDatafeed.ts
Normal file
289
web/src/composables/useTradingViewDatafeed.ts
Normal 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()
|
||||
}
|
||||
181
web/src/composables/useWebSocket.ts
Normal file
181
web/src/composables/useWebSocket.ts
Normal 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
24
web/src/stores/orders.ts
Normal 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 }
|
||||
})
|
||||
@@ -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<string, number>;
|
||||
}
|
||||
|
||||
export type BackendMessage = SnapshotMessage | PatchMessage;
|
||||
export type FrontendMessage = HelloMessage | PatchMessage;
|
||||
export type BackendMessage = SnapshotMessage | PatchMessage | AuthResponseMessage;
|
||||
export type FrontendMessage = AuthMessage | HelloMessage | PatchMessage;
|
||||
|
||||
136
web/src/types/tradingview.ts
Normal file
136
web/src/types/tradingview.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user