Add Bollinger %B indicator and fix credential handling

- Add BollingerPercentB indicator class in backend (BOL%B)
  - Calculates %B = (Close - Lower) / (Upper - Lower)
  - Shows where price is relative to Bollinger Bands
  - Values: 1.0 = upper band, 0.5 = middle, 0.0 = lower
- Add BollingerPercentB JavaScript class for frontend display
  - Creates its own chart (chart4) like RSI/MACD
  - Includes chart binding for synchronized scrolling
- Add chart4 HTML container and data plumbing
- Fix credential update when reconnecting with changed API keys
- Add TESTNET_MODE config setting for live trading mode control

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rob 2026-03-06 06:31:31 -04:00
parent 21d449d877
commit 3e6463e4b3
9 changed files with 144 additions and 5 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
};

View File

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

View File

@ -60,6 +60,7 @@
<!-- Containers for Indicators that require their own charts. -->
<div id="chart2"></div>
<div id="chart3"></div>
<div id="chart4"></div>
<!-- This is the control panel on the right of the screen -->
{% include "control_panel.html" %}
</div><!-- End Master Panel --->

View File

@ -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__':