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:
parent
21d449d877
commit
3e6463e4b3
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 --->
|
||||
|
|
|
|||
|
|
@ -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__':
|
||||
|
|
|
|||
Loading…
Reference in New Issue