diff --git a/src/BrighterTrades.py b/src/BrighterTrades.py index 6360c12..d0323c5 100644 --- a/src/BrighterTrades.py +++ b/src/BrighterTrades.py @@ -1067,7 +1067,8 @@ class BrighterTrades: api_keys=api_keys) if success: self.users.active_exchange(exchange=exchange_name, user_name=user_name, cmd='set') - if api_keys: + # Check if api_keys has actual key/secret values (not just empty dict) + if api_keys and api_keys.get('key') and api_keys.get('secret'): self.users.update_api_keys(api_keys=api_keys, exchange=exchange_name, user_name=user_name) result['status'] = 'success' result['message'] = f'Successfully connected to {exchange_name}.' @@ -1076,7 +1077,8 @@ class BrighterTrades: result['message'] = f'Failed to connect to {exchange_name}.' else: # Exchange is already connected, check if API keys need updating - if api_keys: + # Check if api_keys has actual key/secret values (not just empty dict) + if api_keys and api_keys.get('key') and api_keys.get('secret'): # Get current API keys current_keys = self.users.get_api_keys(user_name, exchange_name) @@ -1146,6 +1148,7 @@ class BrighterTrades: order_type = get_value('orderType') or get_value('order_type', 'MARKET') quantity = get_value('quantity', 0.0) strategy_id = get_value('strategy_id') + testnet = data.get('testnet', False) # Validate required fields if not symbol: @@ -1162,15 +1165,17 @@ class BrighterTrades: order_type=order_type, qty=quantity, user_id=user_id, - strategy_id=strategy_id + strategy_id=strategy_id, + testnet=testnet ) if status == 'Error': logger.warning(f'Error placing the trade: {result}') return {"success": False, "message": result} + mode_str = 'paper' if target == 'test_exchange' else ('testnet' if testnet else 'production') logger.info(f'Trade order received: target={target}, symbol={symbol}, ' - f'side={side}, type={order_type}, quantity={quantity}, price={price}') + f'side={side}, type={order_type}, quantity={quantity}, price={price}, mode={mode_str}') # Get the created trade trade_obj = self.trades.get_trade_by_id(result) diff --git a/src/Users.py b/src/Users.py index 91cfd15..94db5d2 100644 --- a/src/Users.py +++ b/src/Users.py @@ -147,7 +147,12 @@ class UserAccountManagement(BaseUser): if user_name is None: return False - user = self.get_user_data(user_name) + try: + user = self.get_user_data(user_name) + except ValueError: + # User doesn't exist in database (may have been deleted) + return False + if user is None or user.empty: return False @@ -320,7 +325,7 @@ class UserAccountManagement(BaseUser): raise ValueError("Default user template not found in the database.") # Make a deep copy of the default user to preserve the original template - new_user = copy.deepcopy(default_user) + new_user = copy.deepcopy(default_user).reset_index(drop=True) # Modify the deep copied user template with the provided attributes for attr in attrs: @@ -393,7 +398,7 @@ class UserExchangeManagement(UserAccountManagement): if user is None or user.empty or 'api_keys' not in user.columns: return {} - user_keys = user.loc[0, 'api_keys'] + user_keys = user.iloc[0]['api_keys'] user_keys = json.loads(user_keys) if user_keys else {} return user_keys.get(exchange) @@ -406,7 +411,7 @@ class UserExchangeManagement(UserAccountManagement): :param user_name: The name of the user. """ user = self.get_user_data(user_name) - user_keys = user.loc[0, 'api_keys'] + user_keys = user.iloc[0]['api_keys'] user_keys = json.loads(user_keys) if user_keys else {} user_keys.update({exchange: api_keys}) @@ -414,7 +419,7 @@ class UserExchangeManagement(UserAccountManagement): field_name='api_keys', new_data=json.dumps(user_keys)) - configured_exchanges = json.loads(user.loc[0, 'configured_exchanges']) + configured_exchanges = json.loads(user.iloc[0]['configured_exchanges']) if exchange not in configured_exchanges: configured_exchanges.append(exchange) self.modify_user_data(username=user_name, diff --git a/src/app.py b/src/app.py index c1bef3f..206417d 100644 --- a/src/app.py +++ b/src/app.py @@ -483,6 +483,12 @@ def signout(): return redirect('/') +@app.route('/login', methods=['GET']) +def login_page(): + """Display the login page.""" + return render_template('login_page.html', title='Login') + + @app.route('/login', methods=['POST']) def login(): # Get the user_name and password from the form data @@ -491,8 +497,8 @@ def login(): # Validate the input if not username or not password: - flash('Please provide both user_name and password.') - return redirect('/') + flash('Please provide both username and password.', 'login_error') + return redirect('/login') # Attempt to log in the user success = brighter_trades.log_user_in_out(user_name=username, cmd='login', password=password) @@ -500,11 +506,11 @@ def login(): if success: # Store the user_name in the session session['user'] = username - flash('Login successful!') + flash('Login successful!', 'success') + return redirect('/app') else: - flash('Invalid user_name or password.') - - return redirect('/') + flash('Invalid username or password. Please try again.', 'login_error') + return redirect('/login') @app.route('/signup_submit', methods=['POST']) @@ -517,23 +523,23 @@ def signup_submit(): try: validate_email(email) except EmailNotValidError as e: - flash(message=f"Invalid email format: {e}") - return redirect('/signup') # Redirect back to signup page + flash(f"Invalid email format: {e}", 'signup_error') + return redirect('/signup') # Validate user_name and password if not username or not password: - flash(message="Missing user_name or password") - return redirect('/signup') # Redirect back to signup page + flash("Please provide both username and password.", 'signup_error') + return redirect('/signup') # Create a new user success = brighter_trades.create_new_user(email=email, username=username, password=password) if success: session['user'] = username - flash(message="Signup successful! You are now logged in.") - return redirect('/') # Redirect to the main page + flash("Signup successful! You are now logged in.", 'success') + return redirect('/app') else: - flash(message="An error has occurred during the signup process.") - return redirect('/signup') # Redirect back to signup page + flash("Username or email already exists. Please try a different one.", 'signup_error') + return redirect('/signup') diff --git a/src/static/exchanges.js b/src/static/exchanges.js index 99edfc1..0db2358 100644 --- a/src/static/exchanges.js +++ b/src/static/exchanges.js @@ -119,9 +119,18 @@ class Exchanges { const isKeyValid = this.validateApiKey(key); const isSecretKeyValid = this.validateApiKey(secret_key); - // If it's a public exchange, we don't require API keys. + // For public exchanges, API keys are optional (for higher rate limits) + // For non-public exchanges, API keys are required if (isPublicExchange) { - keys = {}; // Clear keys for public exchanges + // If user provided keys, use them; otherwise connect without keys + if (!key && !secret_key) { + keys = {}; // No keys provided, connect as public + } else if (!isKeyValid || !isSecretKeyValid) { + // User provided partial keys - warn them + this.showStatus('Please enter both API key and secret, or leave both empty for public access.', 'error'); + return; + } + // If both keys are valid, keep them (keys object already set) } else if (!isKeyValid || !isSecretKeyValid) { // Validate keys for non-public exchanges this.showStatus('Please enter a valid API key and secret key.', 'error'); diff --git a/src/static/trade.js b/src/static/trade.js index acc993c..daafb49 100644 --- a/src/static/trade.js +++ b/src/static/trade.js @@ -12,7 +12,23 @@ class TradeUIManager { this.orderTypeSelect = null; this.targetSelect = null; this.sideSelect = null; + this.symbolInput = null; + this.testnetCheckbox = null; + this.testnetRow = null; this.onCloseTrade = null; + + // Exchanges known to support testnet/sandbox mode + this.testnetSupportedExchanges = [ + 'binance', 'binanceus', 'binanceusdm', 'binancecoinm', + 'kucoin', 'kucoinfutures', + 'bybit', + 'okx', 'okex', + 'bitget', + 'bitmex', + 'deribit', + 'phemex', + 'mexc' + ]; } /** @@ -29,7 +45,10 @@ class TradeUIManager { tradeValueId = 'tradeValue', orderTypeId = 'orderType', tradeTargetId = 'tradeTarget', - sideId = 'side' + sideId = 'side', + symbolId = 'tradeSymbol', + testnetId = 'tradeTestnet', + testnetRowId = 'testnet-row' } = config; this.targetEl = document.getElementById(targetId); @@ -49,6 +68,9 @@ class TradeUIManager { this.orderTypeSelect = document.getElementById(orderTypeId); this.targetSelect = document.getElementById(tradeTargetId); this.sideSelect = document.getElementById(sideId); + this.symbolInput = document.getElementById(symbolId); + this.testnetCheckbox = document.getElementById(testnetId); + this.testnetRow = document.getElementById(testnetRowId); // Set up event listeners this._setupFormListeners(); @@ -94,13 +116,91 @@ class TradeUIManager { this.qtyInput.addEventListener('change', updateTradeValue); this.qtyInput.addEventListener('input', updateTradeValue); } + + // Trade target (exchange) changes affect testnet visibility + if (this.targetSelect) { + this.targetSelect.addEventListener('change', () => { + this._updateTestnetVisibility(); + }); + } + } + + /** + * Updates testnet checkbox visibility based on selected exchange. + */ + _updateTestnetVisibility() { + if (!this.testnetRow || !this.targetSelect) return; + + const selectedTarget = this.targetSelect.value; + const isPaperTrade = selectedTarget === 'test_exchange'; + + if (isPaperTrade) { + // Hide testnet row for paper trading + this.testnetRow.style.display = 'none'; + } else { + // Show testnet row for live exchanges + this.testnetRow.style.display = 'block'; + + // Check if this exchange supports testnet + const exchangeId = selectedTarget.toLowerCase(); + const supportsTestnet = this.testnetSupportedExchanges.includes(exchangeId); + + const warningEl = document.getElementById('testnet-warning'); + const unavailableEl = document.getElementById('testnet-unavailable'); + + if (supportsTestnet) { + // Enable testnet checkbox + if (this.testnetCheckbox) { + this.testnetCheckbox.disabled = false; + this.testnetCheckbox.checked = true; + } + if (warningEl) warningEl.style.display = 'block'; + if (unavailableEl) unavailableEl.style.display = 'none'; + } else { + // Disable testnet checkbox - this exchange doesn't support it + if (this.testnetCheckbox) { + this.testnetCheckbox.disabled = true; + this.testnetCheckbox.checked = false; + } + if (warningEl) warningEl.style.display = 'none'; + if (unavailableEl) unavailableEl.style.display = 'block'; + } + } + } + + /** + * Populates the exchange selector with connected exchanges. + * @param {string[]} connectedExchanges - List of connected exchange names. + */ + populateExchangeSelector(connectedExchanges) { + if (!this.targetSelect) return; + + // Clear existing options except Paper Trade + while (this.targetSelect.options.length > 1) { + this.targetSelect.remove(1); + } + + // Add connected exchanges + if (connectedExchanges && connectedExchanges.length > 0) { + for (const exchange of connectedExchanges) { + // Skip 'default' exchange used internally + if (exchange.toLowerCase() === 'default') continue; + + const option = document.createElement('option'); + option.value = exchange.toLowerCase(); + option.textContent = `${exchange} (Live)`; + this.targetSelect.appendChild(option); + } + } } /** * Displays the trade creation form. * @param {number} currentPrice - Optional current price to prefill. + * @param {string} symbol - Optional trading pair to prefill. + * @param {string[]} connectedExchanges - Optional list of connected exchanges. */ - displayForm(currentPrice = null) { + displayForm(currentPrice = null, symbol = null, connectedExchanges = null) { if (!this.formElement) { console.error("Form element not initialized."); return; @@ -116,6 +216,27 @@ class TradeUIManager { if (this.currentPriceDisplay) this.currentPriceDisplay.value = currentPrice; } + // Set symbol if available + if (this.symbolInput && symbol) { + this.symbolInput.value = symbol; + } + + // Populate exchange selector + if (connectedExchanges) { + this.populateExchangeSelector(connectedExchanges); + } + + // Reset to paper trade and hide testnet row + if (this.targetSelect) { + this.targetSelect.value = 'test_exchange'; + } + if (this.testnetRow) { + this.testnetRow.style.display = 'none'; + } + if (this.testnetCheckbox) { + this.testnetCheckbox.checked = true; + } + this.formElement.style.display = 'grid'; } @@ -212,6 +333,20 @@ class TradeUIManager { paperBadge.className = 'trade-paper-badge'; paperBadge.textContent = 'PAPER'; tradeItem.appendChild(paperBadge); + } else if (trade.testnet) { + // Testnet badge for live trades + const testnetBadge = document.createElement('span'); + testnetBadge.className = 'trade-testnet-badge'; + testnetBadge.textContent = 'TESTNET'; + testnetBadge.style.cssText = 'background: #28a745; color: white; padding: 1px 4px; border-radius: 3px; font-size: 9px; position: absolute; top: 4px; left: 4px;'; + tradeItem.appendChild(testnetBadge); + } else if (!trade.is_paper) { + // Production badge for live trades (not paper, not testnet) + const prodBadge = document.createElement('span'); + prodBadge.className = 'trade-prod-badge'; + prodBadge.textContent = 'LIVE'; + prodBadge.style.cssText = 'background: #dc3545; color: white; padding: 1px 4px; border-radius: 3px; font-size: 9px; position: absolute; top: 4px; left: 4px;'; + tradeItem.appendChild(prodBadge); } // Trade info container @@ -277,6 +412,10 @@ class TradeUIManager { hoverHtml += `Target: ${trade.target || 'N/A'}`; if (trade.is_paper) { hoverHtml += `Paper Trade`; + } else if (trade.testnet) { + hoverHtml += `Testnet`; + } else { + hoverHtml += `Production (Live)`; } hoverHtml += ``; @@ -688,7 +827,23 @@ class Trade { if (this.data?.price_history && this.data.price_history.length > 0) { currentPrice = this.data.price_history[this.data.price_history.length - 1].close; } - this.uiManager.displayForm(currentPrice); + + // Get symbol from chart + const symbol = this.data?.trading_pair || ''; + + // Get connected exchanges from UI.exchanges + const connectedExchanges = window.UI?.exchanges?.connected_exchanges || []; + + this.uiManager.displayForm(currentPrice, symbol, connectedExchanges); + } + + /** + * Sets the symbol input to the current chart symbol. + */ + useChartSymbol() { + if (this.uiManager.symbolInput && this.data?.trading_pair) { + this.uiManager.symbolInput.value = this.data.trading_pair; + } } /** @@ -714,10 +869,14 @@ class Trade { } const target = this.uiManager.targetSelect?.value || 'test_exchange'; - const symbol = this.data?.trading_pair || ''; + const symbol = this.uiManager.symbolInput?.value || this.data?.trading_pair || ''; const orderType = this.uiManager.orderTypeSelect?.value || 'MARKET'; const side = this.uiManager.sideSelect?.value || 'buy'; + // Get testnet setting (only relevant for live exchanges) + const isPaperTrade = target === 'test_exchange'; + const testnet = isPaperTrade ? false : (this.uiManager.testnetCheckbox?.checked ?? true); + let price; if (orderType === 'MARKET') { price = parseFloat(this.uiManager.currentPriceDisplay?.value || 0); @@ -729,7 +888,7 @@ class Trade { // Validation if (!symbol) { - alert('Please select a trading pair first.'); + alert('Please enter a trading pair.'); return; } if (quantity <= 0) { @@ -741,6 +900,22 @@ class Trade { return; } + // Show confirmation for production live trades + if (!isPaperTrade && !testnet) { + const proceed = confirm( + "WARNING: PRODUCTION MODE\n\n" + + "You are about to execute a LIVE trade with REAL MONEY.\n\n" + + `Exchange: ${target}\n` + + `Symbol: ${symbol}\n` + + `Side: ${side.toUpperCase()}\n` + + `Quantity: ${quantity}\n\n` + + "Are you sure you want to proceed?" + ); + if (!proceed) { + return; + } + } + const tradeData = { target, symbol, @@ -748,6 +923,7 @@ class Trade { side, orderType, quantity, + testnet, user_name: this.data?.user_name }; diff --git a/src/templates/login_page.html b/src/templates/login_page.html new file mode 100644 index 0000000..feef57c --- /dev/null +++ b/src/templates/login_page.html @@ -0,0 +1,180 @@ + + + + + + + + {{ title }} | BrighterTrades + + + + + +
+ +
+
+
+
+
+ +
+ +
+ +

Sign In

+ + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + {% if category in ['login_error', 'error'] %} + + {% elif category == 'success' %} + + {% endif %} + {% endfor %} + {% endif %} + {% endwith %} + + +
+ +
+ +
+ + +
+
+ + +
+ +
+ + +
+
+ + + +
+ +
+ + +

+ Don't have an account? Sign up here +

+ + +

+ Back to Welcome +

+
+
+
+
+
+ + + + + + + + diff --git a/src/templates/new_trade_popup.html b/src/templates/new_trade_popup.html index 7b795e2..c6611d5 100644 --- a/src/templates/new_trade_popup.html +++ b/src/templates/new_trade_popup.html @@ -1,7 +1,7 @@
- -
+ +

Create New Trade

@@ -9,38 +9,59 @@ - - - + Testnet Mode (Recommended for testing) + + + Unchecking uses REAL MONEY on the exchange + + +
+ + + +
+ + +
+ + + + - - - - - - - + + + + - - - + + + - - - 0 + + + 0 - -
+ +
diff --git a/src/templates/sign_up.html b/src/templates/sign_up.html index e350f4b..5db7d75 100644 --- a/src/templates/sign_up.html +++ b/src/templates/sign_up.html @@ -42,14 +42,22 @@

Join BrighterTrades

- + {% with messages = get_flashed_messages(with_categories=true) %} {% if messages %} {% for category, message in messages %} - + {% if category in ['signup_error', 'error', 'message'] %} + + {% elif category == 'success' %} + + {% endif %} + {# Ignore login_error and other unrelated categories #} {% endfor %} {% endif %} {% endwith %} @@ -107,12 +115,11 @@
- +
-

Or sign up with

- - - +

Social login coming soon

+ +
diff --git a/src/templates/welcome.html b/src/templates/welcome.html index ea4d50a..968c6c9 100644 --- a/src/templates/welcome.html +++ b/src/templates/welcome.html @@ -161,7 +161,83 @@
+ + + + + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} + {% if category == 'success' %} +
+ {{ message }} +
+ {% endif %} + {% endfor %} +
+ {% endif %} + {% endwith %}
+ + diff --git a/src/trade.py b/src/trade.py index 85247a8..d8e8ca4 100644 --- a/src/trade.py +++ b/src/trade.py @@ -23,7 +23,7 @@ class Trade: status: str | None = None, stats: dict[str, Any] | None = None, order: Any | None = None, fee: float = 0.001, strategy_id: str | None = None, is_paper: bool = False, creator: int | None = None, created_at: str | None = None, - tbl_key: str | None = None): + tbl_key: str | None = None, testnet: bool = False): """ Initializes a Trade instance with all necessary attributes. """ @@ -39,6 +39,7 @@ class Trade: self.fee = fee self.strategy_id = strategy_id self.is_paper = is_paper + self.testnet = testnet self.creator = creator self.created_at = created_at or dt.datetime.now(dt.timezone.utc).isoformat() @@ -82,6 +83,7 @@ class Trade: 'stats': self.stats, 'order': self.order, 'is_paper': self.is_paper, + 'testnet': self.testnet, 'creator': self.creator, 'created_at': self.created_at } @@ -246,20 +248,40 @@ class Trades: order_price REAL, base_order_qty REAL NOT NULL, time_in_force TEXT DEFAULT 'GTC', - fee REAL DEFAULT 0.1, + fee REAL DEFAULT 0.001, status TEXT DEFAULT 'inactive', stats_json TEXT, strategy_id TEXT, is_paper INTEGER DEFAULT 0, + testnet INTEGER DEFAULT 0, created_at TEXT, tbl_key TEXT UNIQUE ) """ self.data_cache.db.execute_sql(create_sql, params=[]) logger.info("Created trades table in database") + else: + # Ensure testnet column exists for existing databases + self._ensure_testnet_column() except Exception as e: logger.error(f"Error ensuring trades table exists: {e}", exc_info=True) + def _ensure_testnet_column(self) -> None: + """Add testnet column to trades table if it doesn't exist.""" + try: + # Check if testnet column exists + result = self.data_cache.db.execute_sql( + "PRAGMA table_info(trades)", params=[] + ) + columns = {row[1] for row in result} if result else set() + if 'testnet' not in columns: + self.data_cache.db.execute_sql( + "ALTER TABLE trades ADD COLUMN testnet INTEGER DEFAULT 0", params=[] + ) + logger.info("Added testnet column to trades table") + except Exception as e: + logger.debug(f"Could not add testnet column: {e}") + def _create_cache(self) -> None: """Create the trades cache in DataCache.""" try: @@ -284,6 +306,7 @@ class Trades: "stats_json", "strategy_id", "is_paper", + "testnet", "created_at", "tbl_key" ] @@ -323,6 +346,7 @@ class Trades: fee=float(row.get('fee', 0.001)), strategy_id=row.get('strategy_id'), is_paper=bool(row.get('is_paper', 0)), + testnet=bool(row.get('testnet', 0)), creator=row.get('creator'), created_at=row.get('created_at'), tbl_key=row.get('tbl_key') @@ -347,7 +371,7 @@ class Trades: columns = ( "creator", "unique_id", "target", "symbol", "side", "order_type", "order_price", "base_order_qty", "time_in_force", "fee", "status", - "stats_json", "strategy_id", "is_paper", "created_at", "tbl_key" + "stats_json", "strategy_id", "is_paper", "testnet", "created_at", "tbl_key" ) stats_json = json.dumps(trade.stats) if trade.stats else '{}' @@ -367,6 +391,7 @@ class Trades: stats_json, trade.strategy_id, int(trade.is_paper), + int(trade.testnet), trade.created_at, trade.tbl_key ) @@ -432,7 +457,7 @@ class Trades: def new_trade(self, target: str, symbol: str, price: float, side: str, order_type: str, qty: float, user_id: int = None, - strategy_id: str = None) -> tuple[str, str | None]: + strategy_id: str = None, testnet: bool = False) -> tuple[str, str | None]: """ Creates a new trade (paper or live). @@ -444,11 +469,29 @@ class Trades: :param qty: The quantity to trade. :param user_id: The user creating the trade. :param strategy_id: Optional strategy ID if from a strategy. + :param testnet: Whether to use testnet/sandbox mode for live trades. :return: Tuple of (status, trade_id or error message). """ # Determine if this is a paper trade is_paper = target in ['test_exchange', 'paper', 'Paper Trade'] + # For live trades, validate exchange is configured BEFORE creating trade + if not is_paper: + if not self.exchange_connected(): + return 'Error', 'No exchange interface connected. Cannot place live trades.' + + # Check if user has this exchange configured + user_name = self._get_user_name(user_id) if user_id else None + if not user_name: + return 'Error', 'You must be logged in to place live trades.' + + try: + exchange = self.exchange_interface.get_exchange(ename=target, uname=user_name) + if not exchange or not exchange.configured: + return 'Error', f'Exchange "{target}" is not configured with API keys. Please configure it in the Exchanges panel first.' + except ValueError as e: + return 'Error', f'Exchange "{target}" is not connected. Please add it in the Exchanges panel first.' + # For market orders, fetch the current price from exchange effective_price = float(price) if price else 0.0 if order_type and order_type.upper() == 'MARKET' and self.exchange_interface: @@ -489,6 +532,7 @@ class Trades: order_type=order_type.upper() if order_type else 'MARKET', strategy_id=strategy_id, is_paper=is_paper, + testnet=testnet, creator=user_id, fee=effective_fee ) @@ -503,6 +547,9 @@ class Trades: logger.info(f"Paper trade created: {trade.unique_id} {side} {qty} {symbol} @ {effective_price}") else: # Live trade: place order on exchange + mode_str = "testnet" if testnet else "production" + logger.info(f"Live trade ({mode_str}): {trade.unique_id} {side} {qty} {symbol} @ {effective_price}") + if not self.exchange_connected(): return 'Error', 'No exchange connected'