diff --git a/src/BrighterTrades.py b/src/BrighterTrades.py index 9e7b703..2397a77 100644 --- a/src/BrighterTrades.py +++ b/src/BrighterTrades.py @@ -1161,6 +1161,14 @@ class BrighterTrades: # Exchange is in cache but may have been restored from database with broken ccxt client. # Always reconnect with API keys to ensure fresh connection. if api_keys and api_keys.get('key') and api_keys.get('secret'): + # Check if credentials have changed + stored_keys = self.users.get_api_keys(user_name, exchange_name) + credentials_changed = ( + stored_keys.get('key') != api_keys.get('key') or + stored_keys.get('secret') != api_keys.get('secret') or + stored_keys.get('passphrase') != api_keys.get('passphrase') + ) + # Force reconnection to get fresh ccxt client and balances reconnect_ok = self.exchanges.connect_exchange( exchange_name=exchange_name, @@ -1168,6 +1176,9 @@ class BrighterTrades: api_keys=api_keys ) if reconnect_ok: + # Update stored credentials if they changed + if credentials_changed: + self.users.update_api_keys(api_keys=api_keys, exchange=exchange_name, user_name=user_name) result['status'] = 'success' result['message'] = f'{exchange_name}: Reconnected with fresh data.' else: diff --git a/src/config.example.py b/src/config.example.py index c255321..645c5fb 100644 --- a/src/config.example.py +++ b/src/config.example.py @@ -23,6 +23,10 @@ BINANCE_API_SECRET = os.environ.get('BRIGHTER_BINANCE_API_SECRET', '') ALPACA_API_KEY = os.environ.get('BRIGHTER_ALPACA_API_KEY', '') ALPACA_API_SECRET = os.environ.get('BRIGHTER_ALPACA_API_SECRET', '') +# Testnet/sandbox mode - set to True for testnet trading, False for production +# Set via environment variable: export TESTNET_MODE=true +TESTNET_MODE = os.environ.get('TESTNET_MODE', 'false').lower() == 'true' + # Database path - cross-platform, relative to project root _project_root = Path(__file__).parent.parent DB_FILE = str(_project_root / 'data' / 'BrighterTrading.db') diff --git a/src/indicators.py b/src/indicators.py index 69e2095..db274c7 100644 --- a/src/indicators.py +++ b/src/indicators.py @@ -200,6 +200,64 @@ class BolBands(Indicator): return df.iloc[int(self.properties['period']):] +class BollingerPercentB(Indicator): + """ + Bollinger %B indicator shows where price is relative to the bands. + %B = (Close - Lower Band) / (Upper Band - Lower Band) + + Values: + - %B = 1.0: Price at upper band + - %B = 0.5: Price at middle band + - %B = 0.0: Price at lower band + - %B > 1.0: Price above upper band (overbought) + - %B < 0.0: Price below lower band (oversold) + """ + def __init__(self, name: str, indicator_type: str, properties: dict): + super().__init__(name, indicator_type, properties) + # Default display properties (same as Bollinger Bands) + self.properties.setdefault('period', 20) + self.properties.setdefault('devup', 2) + self.properties.setdefault('devdn', 2) + self.properties.setdefault('ma', 0) # Moving average type (0 = SMA) + self.properties.setdefault('color', generate_random_color()) + self.properties.setdefault('thickness', 1) + + def calculate(self, candles: pd.DataFrame, user_name: str, num_results: int = 1) -> pd.DataFrame: + """ + Calculate Bollinger %B indicator. + """ + closes = candles.close.to_numpy(dtype='float') + + # Calculate Bollinger Bands + upper, middle, lower = talib.BBANDS( + closes, + timeperiod=int(self.properties['period']), + nbdevup=int(self.properties['devup']), + nbdevdn=int(self.properties['devdn']), + matype=int(self.properties['ma']) + ) + + # Calculate %B: (Close - Lower) / (Upper - Lower) + # Handle division by zero when bands are equal + band_width = upper - lower + percent_b = np.where(band_width != 0, (closes - lower) / band_width, 0.5) + + # Store the last calculated value + self.properties['value'] = round(float(percent_b[-1]), 4) + + # Create DataFrame with 'time' and 'value' + df = pd.DataFrame({ + 'time': candles.time, + 'value': percent_b + }) + + # Round values + df = df.round({'value': 4}) + + # Slice to skip initial rows where indicator is undefined + return df.iloc[int(self.properties['period']):] + + class MACD(Indicator): def __init__(self, name: str, indicator_type: str, properties: dict): super().__init__(name, indicator_type, properties) @@ -252,6 +310,7 @@ indicators_registry['RSI'] = RSI indicators_registry['LREG'] = LREG indicators_registry['ATR'] = ATR indicators_registry['BOLBands'] = BolBands +indicators_registry['BOL%B'] = BollingerPercentB indicators_registry['MACD'] = MACD diff --git a/src/static/charts.js b/src/static/charts.js index bd28663..552ffa9 100644 --- a/src/static/charts.js +++ b/src/static/charts.js @@ -6,6 +6,7 @@ class Charts { this.chart1_id = idata.chart1_id; this.chart2_id = idata.chart2_id; this.chart3_id = idata.chart3_id; + this.chart4_id = idata.chart4_id; this.trading_pair = idata.trading_pair; this.price_history = idata.price_history; /* A list of bound charts this is necessary for maintaining a dynamic @@ -57,7 +58,14 @@ class Charts { this.chart3 = this.create_chart(this.chart3_id, 100); this.addWatermark(this.chart3, 'MACD'); this.bind_charts(this.chart3); + } + create_PercentB_chart(){ + this.chart4 = this.create_chart(this.chart4_id, 100); + this.set_priceScale(this.chart4, 0.3, 0.0); + // Put the name of the chart in a watermark + this.addWatermark(this.chart4, '%B'); + this.bind_charts(this.chart4); } @@ -120,9 +128,12 @@ class Charts { // if bound_charts has two element in it bind them if (bcl == 2) { this.bind2charts(); } - // if bound_charts has two element in it bind them + // if bound_charts has three elements in it bind them if (bcl == 3) { this.bind3charts(); } + // if bound_charts has four elements in it bind them + if (bcl == 4) { this.bind4charts(); } + return; } add_to_list(chart){ @@ -189,5 +200,24 @@ class Charts { this.bound_charts[2].timeScale().subscribeVisibleTimeRangeChange(syncHandler3); } + bind4charts(){ + // Sync all 4 charts together + let syncFromChart = (sourceIndex) => { + return (e) => { + let barSpacing = this.bound_charts[sourceIndex].timeScale().getBarSpacing(); + let scrollPosition = this.bound_charts[sourceIndex].timeScale().scrollPosition(); + for (let i = 0; i < 4; i++) { + if (i !== sourceIndex) { + this.bound_charts[i].timeScale().applyOptions({ rightOffset: scrollPosition, barSpacing: barSpacing }); + } + } + } + } + this.bound_charts[0].timeScale().subscribeVisibleTimeRangeChange(syncFromChart(0)); + this.bound_charts[1].timeScale().subscribeVisibleTimeRangeChange(syncFromChart(1)); + this.bound_charts[2].timeScale().subscribeVisibleTimeRangeChange(syncFromChart(2)); + this.bound_charts[3].timeScale().subscribeVisibleTimeRangeChange(syncFromChart(3)); + } + } diff --git a/src/static/data.js b/src/static/data.js index 1d659fb..6a5e0e2 100644 --- a/src/static/data.js +++ b/src/static/data.js @@ -7,6 +7,7 @@ class Data { this.chart1_id = 'chart'; this.chart2_id = 'chart2'; this.chart3_id = 'chart3'; + this.chart4_id = 'chart4'; /* Set into memory configuration data from the server. */ // The assets being traded. diff --git a/src/static/general.js b/src/static/general.js index ab763b8..b2cc62b 100644 --- a/src/static/general.js +++ b/src/static/general.js @@ -48,6 +48,7 @@ class User_Interface { chart1_id: this.data.chart1_id, chart2_id: this.data.chart2_id, chart3_id: this.data.chart3_id, + chart4_id: this.data.chart4_id, trading_pair: this.data.trading_pair, price_history: this.data.price_history }; diff --git a/src/static/indicators.js b/src/static/indicators.js index 4667211..d9323f0 100644 --- a/src/static/indicators.js +++ b/src/static/indicators.js @@ -311,6 +311,34 @@ class RSI extends Indicator { } indicatorMap.set("RSI", RSI); +class BollingerPercentB extends Indicator { + constructor(name, charts, color, lineWidth = 2) { + super(name); + if (!charts.hasOwnProperty('chart4')) { + charts.create_PercentB_chart(); + } + let chart = charts.chart4; + this.addLine('line', chart, color, lineWidth); + this.outputs = ['value']; + } + + static getIndicatorConfig() { + return { + args: ['name', 'charts', 'color'], + class: this + }; + } + + init(data) { + this.setLine('line', data, 'value'); + } + + update(data) { + this.updateLine('line', data[0], 'value'); + } +} +indicatorMap.set("BOL%B", BollingerPercentB); + class MACD extends Indicator { constructor(name, charts, color_1, color_2, lineWidth = 2) { super(name); @@ -627,6 +655,8 @@ class Indicators { chart = window.UI.charts.chart2; // Assume RSI is on chart2 } else if (indicator.includes('MACD')) { chart = window.UI.charts.chart3; // Assume MACD is on chart3 + } else if (indicator.includes('%B') || indicator.includes('PercentB')) { + chart = window.UI.charts.chart4; // %B is on chart4 } else { chart = window.UI.charts.chart_1; // Default to the main chart } diff --git a/src/templates/index.html b/src/templates/index.html index a27774b..d2fa725 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -60,6 +60,7 @@
+ {% include "control_panel.html" %} diff --git a/tests/test_BrighterTrades.py b/tests/test_BrighterTrades.py index ade5675..0298c36 100644 --- a/tests/test_BrighterTrades.py +++ b/tests/test_BrighterTrades.py @@ -178,14 +178,15 @@ class TestBrighterTrades(unittest.TestCase): user_name='testuser' ) - def test_connect_or_config_exchange_no_reconnect_when_credentials_unchanged(self): - """Existing exchange should remain connected when submitted credentials are unchanged.""" + def test_connect_or_config_exchange_reconnects_when_already_connected(self): + """Existing exchange should reconnect to ensure fresh ccxt client (fixes pickle corruption).""" connected_cache = MagicMock() connected_cache.empty = False self.mock_data_cache.get_serialized_datacache.return_value = connected_cache same_keys = {'key': 'same_key', 'secret': 'same_secret', 'passphrase': 'same_phrase'} self.mock_users.get_api_keys.return_value = same_keys + self.mock_exchanges.connect_exchange.return_value = True result = self.brighter_trades.connect_or_config_exchange( user_name='testuser', @@ -193,8 +194,9 @@ class TestBrighterTrades(unittest.TestCase): api_keys=same_keys ) - self.assertEqual(result['status'], 'already_connected') - self.mock_exchanges.connect_exchange.assert_not_called() + # Should reconnect to fix pickle corruption issues with ccxt client + self.assertEqual(result['status'], 'success') + self.mock_exchanges.connect_exchange.assert_called_once() self.mock_users.update_api_keys.assert_not_called() if __name__ == '__main__':