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.
|
# Exchange is in cache but may have been restored from database with broken ccxt client.
|
||||||
# Always reconnect with API keys to ensure fresh connection.
|
# Always reconnect with API keys to ensure fresh connection.
|
||||||
if api_keys and api_keys.get('key') and api_keys.get('secret'):
|
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
|
# Force reconnection to get fresh ccxt client and balances
|
||||||
reconnect_ok = self.exchanges.connect_exchange(
|
reconnect_ok = self.exchanges.connect_exchange(
|
||||||
exchange_name=exchange_name,
|
exchange_name=exchange_name,
|
||||||
|
|
@ -1168,6 +1176,9 @@ class BrighterTrades:
|
||||||
api_keys=api_keys
|
api_keys=api_keys
|
||||||
)
|
)
|
||||||
if reconnect_ok:
|
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['status'] = 'success'
|
||||||
result['message'] = f'{exchange_name}: Reconnected with fresh data.'
|
result['message'] = f'{exchange_name}: Reconnected with fresh data.'
|
||||||
else:
|
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_KEY = os.environ.get('BRIGHTER_ALPACA_API_KEY', '')
|
||||||
ALPACA_API_SECRET = os.environ.get('BRIGHTER_ALPACA_API_SECRET', '')
|
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
|
# Database path - cross-platform, relative to project root
|
||||||
_project_root = Path(__file__).parent.parent
|
_project_root = Path(__file__).parent.parent
|
||||||
DB_FILE = str(_project_root / 'data' / 'BrighterTrading.db')
|
DB_FILE = str(_project_root / 'data' / 'BrighterTrading.db')
|
||||||
|
|
|
||||||
|
|
@ -200,6 +200,64 @@ class BolBands(Indicator):
|
||||||
return df.iloc[int(self.properties['period']):]
|
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):
|
class MACD(Indicator):
|
||||||
def __init__(self, name: str, indicator_type: str, properties: dict):
|
def __init__(self, name: str, indicator_type: str, properties: dict):
|
||||||
super().__init__(name, indicator_type, properties)
|
super().__init__(name, indicator_type, properties)
|
||||||
|
|
@ -252,6 +310,7 @@ indicators_registry['RSI'] = RSI
|
||||||
indicators_registry['LREG'] = LREG
|
indicators_registry['LREG'] = LREG
|
||||||
indicators_registry['ATR'] = ATR
|
indicators_registry['ATR'] = ATR
|
||||||
indicators_registry['BOLBands'] = BolBands
|
indicators_registry['BOLBands'] = BolBands
|
||||||
|
indicators_registry['BOL%B'] = BollingerPercentB
|
||||||
indicators_registry['MACD'] = MACD
|
indicators_registry['MACD'] = MACD
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ class Charts {
|
||||||
this.chart1_id = idata.chart1_id;
|
this.chart1_id = idata.chart1_id;
|
||||||
this.chart2_id = idata.chart2_id;
|
this.chart2_id = idata.chart2_id;
|
||||||
this.chart3_id = idata.chart3_id;
|
this.chart3_id = idata.chart3_id;
|
||||||
|
this.chart4_id = idata.chart4_id;
|
||||||
this.trading_pair = idata.trading_pair;
|
this.trading_pair = idata.trading_pair;
|
||||||
this.price_history = idata.price_history;
|
this.price_history = idata.price_history;
|
||||||
/* A list of bound charts this is necessary for maintaining a dynamic
|
/* 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.chart3 = this.create_chart(this.chart3_id, 100);
|
||||||
this.addWatermark(this.chart3, 'MACD');
|
this.addWatermark(this.chart3, 'MACD');
|
||||||
this.bind_charts(this.chart3);
|
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 bound_charts has two element in it bind them
|
||||||
if (bcl == 2) { this.bind2charts(); }
|
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 (bcl == 3) { this.bind3charts(); }
|
||||||
|
|
||||||
|
// if bound_charts has four elements in it bind them
|
||||||
|
if (bcl == 4) { this.bind4charts(); }
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
add_to_list(chart){
|
add_to_list(chart){
|
||||||
|
|
@ -189,5 +200,24 @@ class Charts {
|
||||||
this.bound_charts[2].timeScale().subscribeVisibleTimeRangeChange(syncHandler3);
|
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.chart1_id = 'chart';
|
||||||
this.chart2_id = 'chart2';
|
this.chart2_id = 'chart2';
|
||||||
this.chart3_id = 'chart3';
|
this.chart3_id = 'chart3';
|
||||||
|
this.chart4_id = 'chart4';
|
||||||
|
|
||||||
/* Set into memory configuration data from the server. */
|
/* Set into memory configuration data from the server. */
|
||||||
// The assets being traded.
|
// The assets being traded.
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ class User_Interface {
|
||||||
chart1_id: this.data.chart1_id,
|
chart1_id: this.data.chart1_id,
|
||||||
chart2_id: this.data.chart2_id,
|
chart2_id: this.data.chart2_id,
|
||||||
chart3_id: this.data.chart3_id,
|
chart3_id: this.data.chart3_id,
|
||||||
|
chart4_id: this.data.chart4_id,
|
||||||
trading_pair: this.data.trading_pair,
|
trading_pair: this.data.trading_pair,
|
||||||
price_history: this.data.price_history
|
price_history: this.data.price_history
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -311,6 +311,34 @@ class RSI extends Indicator {
|
||||||
}
|
}
|
||||||
indicatorMap.set("RSI", RSI);
|
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 {
|
class MACD extends Indicator {
|
||||||
constructor(name, charts, color_1, color_2, lineWidth = 2) {
|
constructor(name, charts, color_1, color_2, lineWidth = 2) {
|
||||||
super(name);
|
super(name);
|
||||||
|
|
@ -627,6 +655,8 @@ class Indicators {
|
||||||
chart = window.UI.charts.chart2; // Assume RSI is on chart2
|
chart = window.UI.charts.chart2; // Assume RSI is on chart2
|
||||||
} else if (indicator.includes('MACD')) {
|
} else if (indicator.includes('MACD')) {
|
||||||
chart = window.UI.charts.chart3; // Assume MACD is on chart3
|
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 {
|
} else {
|
||||||
chart = window.UI.charts.chart_1; // Default to the main chart
|
chart = window.UI.charts.chart_1; // Default to the main chart
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@
|
||||||
<!-- Containers for Indicators that require their own charts. -->
|
<!-- Containers for Indicators that require their own charts. -->
|
||||||
<div id="chart2"></div>
|
<div id="chart2"></div>
|
||||||
<div id="chart3"></div>
|
<div id="chart3"></div>
|
||||||
|
<div id="chart4"></div>
|
||||||
<!-- This is the control panel on the right of the screen -->
|
<!-- This is the control panel on the right of the screen -->
|
||||||
{% include "control_panel.html" %}
|
{% include "control_panel.html" %}
|
||||||
</div><!-- End Master Panel --->
|
</div><!-- End Master Panel --->
|
||||||
|
|
|
||||||
|
|
@ -178,14 +178,15 @@ class TestBrighterTrades(unittest.TestCase):
|
||||||
user_name='testuser'
|
user_name='testuser'
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_connect_or_config_exchange_no_reconnect_when_credentials_unchanged(self):
|
def test_connect_or_config_exchange_reconnects_when_already_connected(self):
|
||||||
"""Existing exchange should remain connected when submitted credentials are unchanged."""
|
"""Existing exchange should reconnect to ensure fresh ccxt client (fixes pickle corruption)."""
|
||||||
connected_cache = MagicMock()
|
connected_cache = MagicMock()
|
||||||
connected_cache.empty = False
|
connected_cache.empty = False
|
||||||
self.mock_data_cache.get_serialized_datacache.return_value = connected_cache
|
self.mock_data_cache.get_serialized_datacache.return_value = connected_cache
|
||||||
|
|
||||||
same_keys = {'key': 'same_key', 'secret': 'same_secret', 'passphrase': 'same_phrase'}
|
same_keys = {'key': 'same_key', 'secret': 'same_secret', 'passphrase': 'same_phrase'}
|
||||||
self.mock_users.get_api_keys.return_value = same_keys
|
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(
|
result = self.brighter_trades.connect_or_config_exchange(
|
||||||
user_name='testuser',
|
user_name='testuser',
|
||||||
|
|
@ -193,8 +194,9 @@ class TestBrighterTrades(unittest.TestCase):
|
||||||
api_keys=same_keys
|
api_keys=same_keys
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result['status'], 'already_connected')
|
# Should reconnect to fix pickle corruption issues with ccxt client
|
||||||
self.mock_exchanges.connect_exchange.assert_not_called()
|
self.assertEqual(result['status'], 'success')
|
||||||
|
self.mock_exchanges.connect_exchange.assert_called_once()
|
||||||
self.mock_users.update_api_keys.assert_not_called()
|
self.mock_users.update_api_keys.assert_not_called()
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue