Add public strategy subscription system

Implement a subscription system for public strategies:

Security & Auth:
- Bind WebSocket user identity at connect time (prevents spoofing)
- Add server-side ownership verification for all destructive operations
- Invalidate socket connections on logout
- Add XSS protection with HTML/JS escaping in frontend

Database:
- Add strategy_subscriptions table with proper indexes
- Fix get_all_rows_from_datacache to fall back to DB when cache empty

Backend:
- Add subscribe/unsubscribe endpoints with authorization checks
- Add get_user_strategies (owned + subscribed) and get_public_strategies_catalog
- Propagate indicator_owner_id through strategy instances for subscribed strategies
- Redact strategy internals (code, workspace) for non-owners

Frontend:
- Add "Add Public" button to browse and subscribe to public strategies
- Show subscribed strategies with creator badge and unsubscribe button
- Prevent editing of subscribed strategies (show info modal instead)
- Add public strategy browser modal

Tests:
- Update authorization tests for new subscription-required model

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rob 2026-03-08 16:59:52 -03:00
parent 11c1310f49
commit ee16023b6b
12 changed files with 1229 additions and 161 deletions

View File

@ -645,11 +645,14 @@ class BrighterTrades:
logger.error(f"Error editing strategy: {e}", exc_info=True)
return {"success": False, "message": "An unexpected error occurred while editing the strategy"}
def delete_strategy(self, data: dict) -> dict:
def delete_strategy(self, data: dict, user_id: int = None) -> dict:
"""
Deletes the specified strategy identified by tbl_key from the strategies instance.
:param data: Dictionary containing 'tbl_key' and 'user_name'.
Security: Ownership is verified before deletion to prevent unauthorized access.
:param data: Dictionary containing 'tbl_key'.
:param user_id: The authenticated user ID (for ownership verification).
:return: A dictionary indicating success or failure with an appropriate message and the tbl_key.
"""
# Validate tbl_key
@ -657,8 +660,12 @@ class BrighterTrades:
if not tbl_key:
return {"success": False, "message": "tbl_key not provided", "tbl_key": None}
# Call the delete_strategy method to remove the strategy
result = self.strategies.delete_strategy(tbl_key=tbl_key)
# Get user_id from data if not passed directly (backwards compatibility)
if user_id is None:
user_id = data.get('user_id') or data.get('userId')
# Call the delete_strategy method to remove the strategy (with ownership check)
result = self.strategies.delete_strategy(tbl_key=tbl_key, user_id=user_id)
# Return the result with tbl_key included
if result.get('success'):
@ -724,42 +731,27 @@ class BrighterTrades:
strategy_row = strategy_data.iloc[0]
strategy_name = strategy_row.get('name', 'Unknown')
# Authorization check: user must own the strategy or strategy must be public
# Authorization check: user must own the strategy OR be subscribed to it
strategy_creator = strategy_row.get('creator')
is_public = bool(strategy_row.get('public', False))
if not is_public:
requester_name = None
try:
requester_name = self.users.get_username(user_id=user_id)
except Exception:
logger.warning(f"Unable to resolve username for user id '{user_id}'.")
creator_id = int(strategy_creator) if strategy_creator is not None else None
except (ValueError, TypeError):
creator_id = None
creator_str = str(strategy_creator) if strategy_creator is not None else ''
requester_id_str = str(user_id)
is_owner = (creator_id == user_id) if (creator_id is not None and user_id is not None) else False
is_subscribed = self.strategies.is_subscribed(user_id, strategy_id)
creator_matches_user = False
if creator_str:
# Support creator being stored as user_name or user_id.
creator_matches_user = (
(requester_name is not None and creator_str == requester_name) or
(creator_str == requester_id_str)
)
if not creator_matches_user and creator_str:
# Also check if creator is a username that resolves to the current user id.
try:
creator_id = self.get_user_info(user_name=creator_str, info='User_id')
creator_matches_user = creator_id == user_id
except Exception:
creator_matches_user = False
if not creator_matches_user:
# Must be owner OR subscribed to run
if not is_owner and not is_subscribed:
return {
"success": False,
"message": "You do not have permission to run this strategy."
"message": "Subscribe to this strategy first"
}
# For subscribed strategies, use creator's indicators
# This ensures subscribers run with the creator's indicator definitions
indicator_owner_id = creator_id if is_subscribed and not is_owner else None
# Check if already running
instance_key = (user_id, strategy_id, effective_mode)
if instance_key in self.strategies.active_instances:
@ -917,6 +909,7 @@ class BrighterTrades:
testnet=actual_testnet,
max_position_pct=max_position_pct,
circuit_breaker_pct=circuit_breaker_pct,
indicator_owner_id=indicator_owner_id, # For subscribed strategies, use creator's indicators
)
# Store the active instance
@ -1115,11 +1108,14 @@ class BrighterTrades:
def get_strategies_json(self, user_id) -> list:
"""
Retrieve all public and user strategies from the strategies instance and return them as a list of dictionaries.
Retrieve strategies that the user owns or is subscribed to.
Returns owned strategies with full data and subscribed strategies with redacted internals.
:param user_id: The user's ID.
:return: list - A list of dictionaries, each representing a strategy.
"""
return self.strategies.get_all_strategies(user_id, 'dict')
return self.strategies.get_user_strategies(user_id)
def connect_or_config_exchange(self, user_name: str, exchange_name: str, api_keys: dict = None) -> dict:
"""
@ -1427,14 +1423,21 @@ class BrighterTrades:
return
def process_incoming_message(self, msg_type: str, msg_data: dict, socket_conn_id: str) -> dict | None:
def process_incoming_message(
self,
msg_type: str,
msg_data: dict,
socket_conn_id: str,
authenticated_user_id: int = None
) -> dict | None:
"""
Processes an incoming message and performs the corresponding actions based on the message type and data.
:param socket_conn_id: The WebSocket connection to send updates back to the client.
:param msg_type: The type of the incoming message.
:param msg_data: The data associated with the incoming message.
:param socket_conn_id: The WebSocket connection to send updates back to the client.
:param authenticated_user_id: Server-verified user ID from socket mapping. If provided, this takes
precedence over any user identity in msg_data.
:return: dict|None - A dictionary containing the response message and data, or None if no response is needed or
no data is found to ensure the WebSocket channel isn't burdened with unnecessary
communication.
@ -1447,6 +1450,12 @@ class BrighterTrades:
""" Formats a standard reply message. """
return {"reply": reply_msg, "data": reply_data}
# Use authenticated_user_id if provided (from secure socket mapping)
# Otherwise fall back to resolving from msg_data (for backwards compatibility)
if authenticated_user_id is not None:
user_id = authenticated_user_id
user_name = self.users.get_username(user_id=authenticated_user_id)
else:
user_name = self.resolve_user_name(msg_data)
user_id = self.resolve_user_id(msg_data, user_name=user_name)
@ -1470,8 +1479,9 @@ class BrighterTrades:
elif request_for == 'strategies':
if user_id is None:
return standard_reply("strategy_error", {"message": "User not specified"})
if strategies := self.get_strategies_json(user_id):
return standard_reply("strategies", strategies)
# Always return response, even if empty list
strategies = self.get_strategies_json(user_id)
return standard_reply("strategies", strategies or [])
elif request_for == 'trades':
trades = self.get_trades(user_id)
@ -1496,7 +1506,8 @@ class BrighterTrades:
})
if msg_type == 'delete_strategy':
result = self.delete_strategy(msg_data)
# Pass authenticated user_id for ownership verification
result = self.delete_strategy(msg_data, user_id=user_id)
if result.get('success'):
return standard_reply("strategy_deleted", {
"message": result.get('message'),
@ -1716,6 +1727,39 @@ class BrighterTrades:
logger.error(f"Error getting strategy status: {e}", exc_info=True)
return standard_reply("strategy_status_error", {"message": f"Failed to get status: {str(e)}"})
# ===== Strategy Subscription Handlers =====
if msg_type == 'subscribe_strategy':
strategy_tbl_key = msg_data.get('strategy_tbl_key') or msg_data.get('tbl_key')
if not strategy_tbl_key:
return standard_reply("subscription_error", {"message": "Strategy not specified"})
result = self.strategies.subscribe_to_strategy(user_id, strategy_tbl_key)
if result.get('success'):
return standard_reply("strategy_subscribed", result)
else:
return standard_reply("subscription_error", result)
if msg_type == 'unsubscribe_strategy':
strategy_tbl_key = msg_data.get('strategy_tbl_key') or msg_data.get('tbl_key')
if not strategy_tbl_key:
return standard_reply("subscription_error", {"message": "Strategy not specified"})
result = self.strategies.unsubscribe_from_strategy(user_id, strategy_tbl_key)
if result.get('success'):
return standard_reply("strategy_unsubscribed", result)
else:
return standard_reply("subscription_error", result)
if msg_type == 'get_public_strategies':
# Returns all public strategies for the browse dialog
try:
strategies = self.strategies.get_public_strategies_catalog(user_id)
return standard_reply("public_strategies", {"strategies": strategies or []})
except Exception as e:
logger.error(f"Error getting public strategies: {e}", exc_info=True)
return standard_reply("public_strategies_error", {"message": str(e)})
if msg_type == 'reply':
# If the message is a reply log the response to the terminal.
print(f"\napp.py:Received reply: {msg_data}")

View File

@ -923,12 +923,24 @@ class DatabaseInteractions(SnapshotDataCache):
# Case 1: Retrieve all rows from the cache
if isinstance(cache, RowBasedCache):
return pd.DataFrame.from_dict(cache.get_all_items(), orient='index')
result = pd.DataFrame.from_dict(cache.get_all_items(), orient='index')
if not result.empty:
return result
elif isinstance(cache, TableBasedCache):
return cache.get_all_items()
result = cache.get_all_items()
if not result.empty:
return result
# Case 2: Fallback to retrieve all rows from the database using Database class
return self.db.get_all_rows(cache_name)
# Case 2: Fallback to retrieve all rows from the database if cache is empty
db_result = self.db.get_all_rows(cache_name)
# Populate the cache with database results for future queries
if db_result is not None and not db_result.empty and cache is not None:
if isinstance(cache, TableBasedCache):
cache.add_table(df=db_result)
# For RowBasedCache, we'd need a key which we don't have here
return db_result if db_result is not None else pd.DataFrame()
def _fetch_from_database_with_list_support(self, cache_name: str,
filter_vals: List[tuple[str, Any]]) -> pd.DataFrame:

View File

@ -62,6 +62,19 @@ class Strategies:
]
)
# Create a cache for strategy subscriptions
self.data_cache.create_cache(
name='strategy_subscriptions',
cache_type='table',
size_limit=1000,
eviction_policy='deny',
default_expiration=dt.timedelta(hours=24),
columns=["id", "user_id", "strategy_tbl_key", "subscribed_at"]
)
# Ensure the subscriptions table exists in SQLite
self._ensure_subscriptions_table()
# Initialize default settings
self.default_timeframe = '5m'
self.default_exchange = 'Binance'
@ -97,6 +110,46 @@ class Strategies:
except Exception as e:
logger.warning(f"Migration check failed (may be expected on fresh install): {e}")
def _ensure_subscriptions_table(self) -> None:
"""
Create the strategy_subscriptions table if it doesn't exist.
Note: execute_sql runs single statements only, so we run each statement separately.
We don't rely on FK ON DELETE CASCADE since SQLite requires PRAGMA foreign_keys = ON.
"""
import config
try:
db = self.data_cache.db
# Statement 1: Create table
db.execute_sql("""
CREATE TABLE IF NOT EXISTS strategy_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
strategy_tbl_key TEXT NOT NULL,
subscribed_at TEXT NOT NULL,
UNIQUE(user_id, strategy_tbl_key)
)
""")
# Statement 2: Create user index
db.execute_sql("""
CREATE INDEX IF NOT EXISTS idx_subscriptions_user
ON strategy_subscriptions(user_id)
""")
# Statement 3: Create strategy index
db.execute_sql("""
CREATE INDEX IF NOT EXISTS idx_subscriptions_strategy
ON strategy_subscriptions(strategy_tbl_key)
""")
logger.info("Strategy subscriptions table initialized")
except Exception as e:
logger.warning(f"Failed to create subscriptions table (may already exist): {e}")
def update(self, candle_data: dict = None) -> list:
"""
Update all active strategy instances with new price data.
@ -188,6 +241,7 @@ class Strategies:
testnet: bool = True,
max_position_pct: float = 0.5,
circuit_breaker_pct: float = -0.10,
indicator_owner_id: int = None,
) -> StrategyInstance:
"""
Factory method to create the appropriate strategy instance based on mode.
@ -206,6 +260,7 @@ class Strategies:
:param testnet: Use testnet for live trading (default True for safety).
:param max_position_pct: Maximum position size as % of balance for live trading.
:param circuit_breaker_pct: Drawdown % to halt trading for live trading.
:param indicator_owner_id: For subscribed strategies, the creator's user ID for indicator lookup.
:return: Strategy instance appropriate for the mode.
"""
mode = mode.lower()
@ -226,6 +281,7 @@ class Strategies:
commission=commission,
slippage=slippage if slippage > 0 else 0.0005,
price_provider=price_provider,
indicator_owner_id=indicator_owner_id,
)
elif mode == TradingMode.BACKTEST:
@ -240,6 +296,7 @@ class Strategies:
indicators=self.indicators_manager,
trades=self.trades,
edm_client=self.edm_client,
indicator_owner_id=indicator_owner_id,
)
elif mode == TradingMode.LIVE:
@ -280,6 +337,7 @@ class Strategies:
slippage=slippage,
max_position_pct=max_position_pct,
circuit_breaker_pct=circuit_breaker_pct,
indicator_owner_id=indicator_owner_id,
)
else:
@ -295,6 +353,7 @@ class Strategies:
indicators=self.indicators_manager,
trades=self.trades,
edm_client=self.edm_client,
indicator_owner_id=indicator_owner_id,
)
def _save_strategy(self, strategy_data: dict, default_source: dict) -> dict:
@ -319,6 +378,16 @@ class Strategies:
)
if existing_strategy.empty:
return {"success": False, "message": "Strategy not found."}
# Check if strategy is being made private (public -> private transition)
was_public = bool(existing_strategy.iloc[0].get('public', 0))
is_public = bool(strategy_data.get('public', 0))
if was_public and not is_public:
# Strategy being made private - remove all subscriptions
self._remove_all_subscriptions_for_strategy(tbl_key)
logger.info(f"Strategy '{tbl_key}' made private - removed all subscriptions")
else:
# Check for duplicate strategy name
filter_conditions = [
@ -333,6 +402,13 @@ class Strategies:
if not existing_strategy.empty:
return {"success": False, "message": "A strategy with this name already exists"}
# Check unique public name requirement
is_public = bool(strategy_data.get('public', 0))
if is_public:
exclude_key = tbl_key if is_edit else None
if not self._check_unique_public_name(strategy_data['name'], exclude_tbl_key=exclude_key):
return {"success": False, "message": "A public strategy with this name already exists"}
# Validate and serialize 'workspace'
workspace_data = strategy_data.get('workspace')
if not isinstance(workspace_data, str) or not workspace_data.strip():
@ -448,14 +524,352 @@ class Strategies:
"""
return self._save_strategy(strategy_data, default_source)
def delete_strategy(self, tbl_key: str) -> dict:
def verify_ownership(self, user_id: int, strategy_tbl_key: str) -> bool:
"""
Verify that the user owns the strategy.
:param user_id: The ID of the user to check.
:param strategy_tbl_key: The tbl_key of the strategy.
:return: True if the user owns the strategy, False otherwise.
"""
if user_id is None or not strategy_tbl_key:
return False
strategy = self.get_strategy_by_tbl_key(strategy_tbl_key)
if not strategy:
return False
creator = strategy.get('creator')
if creator is None:
return False
try:
return int(creator) == int(user_id)
except (ValueError, TypeError):
return False
def _remove_all_subscriptions_for_strategy(self, strategy_tbl_key: str) -> None:
"""
Remove all subscriptions to a strategy.
Called when a strategy is deleted or made private.
We explicitly delete subscriptions rather than relying on FK cascade
since SQLite requires PRAGMA foreign_keys = ON.
:param strategy_tbl_key: The tbl_key of the strategy.
"""
try:
# Remove from cache and database (remove_row_from_datacache handles both)
self.data_cache.remove_row_from_datacache(
cache_name='strategy_subscriptions',
filter_vals=[('strategy_tbl_key', strategy_tbl_key)]
)
logger.info(f"Removed all subscriptions for strategy '{strategy_tbl_key}'")
except Exception as e:
logger.warning(f"Failed to remove subscriptions for strategy '{strategy_tbl_key}': {e}")
def _get_username_for_id(self, user_id: int) -> str:
"""
Get username for a user ID. Used for displaying creator names.
:param user_id: The user's ID.
:return: The username or a fallback string.
"""
if user_id is None:
return "Unknown"
try:
# Direct lookup via data_cache
users_cache = self.data_cache.get_rows_from_datacache(
cache_name='users',
filter_vals=[('id', int(user_id))],
include_tbl_key=True
)
if not users_cache.empty:
return users_cache.iloc[0].get('user_name', f'User #{user_id}')
except Exception as e:
logger.debug(f"Failed to get username for user_id {user_id}: {e}")
return f'User #{user_id}' # Fallback
def _redact_strategy_internals(self, strategy: dict) -> dict:
"""
Remove sensitive internals from strategy for non-owners.
This prevents subscribers from seeing the strategy's implementation details.
:param strategy: The full strategy dictionary.
:return: A redacted copy of the strategy.
"""
redacted = strategy.copy()
redacted['workspace'] = None
redacted['code'] = None
redacted['strategy_components'] = None # CRITICAL: contains generated code
return redacted
def is_subscribed(self, user_id: int, strategy_tbl_key: str) -> bool:
"""
Check if a user is subscribed to a strategy.
:param user_id: The user's ID.
:param strategy_tbl_key: The strategy's tbl_key.
:return: True if subscribed, False otherwise.
"""
if user_id is None or not strategy_tbl_key:
return False
try:
subscriptions = self.data_cache.get_rows_from_datacache(
cache_name='strategy_subscriptions',
filter_vals=[('user_id', user_id), ('strategy_tbl_key', strategy_tbl_key)]
)
return not subscriptions.empty
except Exception:
return False
def _is_strategy_running_for_user(self, user_id: int, strategy_tbl_key: str) -> bool:
"""
Check if user has a running instance of the given strategy.
:param user_id: The user's ID.
:param strategy_tbl_key: The strategy's tbl_key.
:return: True if the strategy is running for this user.
"""
for (uid, sid, mode) in self.active_instances.keys():
if uid == user_id and sid == strategy_tbl_key:
return True
return False
def subscribe_to_strategy(self, user_id: int, strategy_tbl_key: str) -> dict:
"""
Subscribe a user to a public strategy.
:param user_id: The user's ID.
:param strategy_tbl_key: The strategy's tbl_key.
:return: A dictionary indicating success or failure.
"""
if user_id is None or not strategy_tbl_key:
return {"success": False, "message": "Invalid user or strategy"}
# Get the strategy
strategy = self.get_strategy_by_tbl_key(strategy_tbl_key)
if not strategy:
return {"success": False, "message": "Strategy not found"}
# Check if strategy is public
if not strategy.get('public'):
return {"success": False, "message": "Cannot subscribe to private strategy"}
# Check if user is the owner
if self.verify_ownership(user_id, strategy_tbl_key):
return {"success": False, "message": "You cannot subscribe to your own strategy"}
# Check if already subscribed
if self.is_subscribed(user_id, strategy_tbl_key):
return {"success": False, "message": "Already subscribed to this strategy"}
try:
# Add subscription via datacache (handles both cache and DB insert)
subscribed_at = dt.datetime.now(dt.timezone.utc).isoformat()
self.data_cache.insert_row_into_datacache(
cache_name='strategy_subscriptions',
columns=("user_id", "strategy_tbl_key", "subscribed_at"),
values=(user_id, strategy_tbl_key, subscribed_at)
)
logger.info(f"User {user_id} subscribed to strategy '{strategy_tbl_key}'")
return {
"success": True,
"message": "Successfully subscribed to strategy",
"strategy_name": strategy.get('name')
}
except Exception as e:
logger.error(f"Failed to subscribe user {user_id} to strategy '{strategy_tbl_key}': {e}")
return {"success": False, "message": f"Failed to subscribe: {str(e)}"}
def unsubscribe_from_strategy(self, user_id: int, strategy_tbl_key: str) -> dict:
"""
Unsubscribe a user from a strategy.
:param user_id: The user's ID.
:param strategy_tbl_key: The strategy's tbl_key.
:return: A dictionary indicating success or failure.
"""
if user_id is None or not strategy_tbl_key:
return {"success": False, "message": "Invalid user or strategy"}
# Check if subscribed
if not self.is_subscribed(user_id, strategy_tbl_key):
return {"success": False, "message": "Not subscribed to this strategy"}
# Check if strategy is running for this user
if self._is_strategy_running_for_user(user_id, strategy_tbl_key):
return {"success": False, "message": "Stop the strategy before unsubscribing"}
try:
# Remove from cache and database (remove_row_from_datacache handles both)
self.data_cache.remove_row_from_datacache(
cache_name='strategy_subscriptions',
filter_vals=[('user_id', user_id), ('strategy_tbl_key', strategy_tbl_key)]
)
logger.info(f"User {user_id} unsubscribed from strategy '{strategy_tbl_key}'")
return {"success": True, "message": "Successfully unsubscribed from strategy"}
except Exception as e:
logger.error(f"Failed to unsubscribe user {user_id} from strategy '{strategy_tbl_key}': {e}")
return {"success": False, "message": f"Failed to unsubscribe: {str(e)}"}
def get_user_strategies(self, user_id: int) -> list:
"""
Get strategies that a user owns OR is subscribed to.
This is the primary method for getting a user's strategy list.
Subscribed strategies are redacted (no code/workspace visible).
:param user_id: The user's ID.
:return: List of strategy dictionaries.
"""
result = []
if user_id is None:
return result
# Get user's own strategies (public or private)
owned = self.data_cache.get_rows_from_datacache(
cache_name='strategies',
filter_vals=[('creator', user_id)],
include_tbl_key=True
)
# Add owned strategies (full access)
if owned is not None and not owned.empty:
for _, row in owned.iterrows():
strat = row.to_dict()
strat['is_owner'] = True
strat['is_subscribed'] = False
# Deserialize JSON fields
if isinstance(strat.get('default_source'), str):
try:
strat['default_source'] = json.loads(strat['default_source'])
except json.JSONDecodeError:
strat['default_source'] = {}
if isinstance(strat.get('stats'), str):
try:
strat['stats'] = json.loads(strat['stats'])
except json.JSONDecodeError:
strat['stats'] = {}
result.append(strat)
# Get subscribed strategy keys
subscriptions = self.data_cache.get_rows_from_datacache(
cache_name='strategy_subscriptions',
filter_vals=[('user_id', user_id)]
)
# Add subscribed strategies (redacted)
if subscriptions is not None and not subscriptions.empty:
for _, sub in subscriptions.iterrows():
strategy_key = sub.get('strategy_tbl_key')
strategy = self.get_strategy_by_tbl_key(strategy_key)
if strategy and strategy.get('public'): # Still public
strat = self._redact_strategy_internals(strategy)
strat['is_owner'] = False
strat['is_subscribed'] = True
strat['creator_name'] = self._get_username_for_id(strategy.get('creator'))
result.append(strat)
return result
def get_public_strategies_catalog(self, user_id: int) -> list:
"""
Get all public strategies for the browse dialog (sanitized).
Does not include the user's own strategies.
:param user_id: The user's ID (to exclude their own strategies).
:return: List of sanitized strategy dictionaries.
"""
result = []
# Get all public strategies
public = self.data_cache.get_rows_from_datacache(
cache_name='strategies',
filter_vals=[('public', 1)],
include_tbl_key=True
)
if public is None or public.empty:
return result
for _, row in public.iterrows():
creator = row.get('creator')
tbl_key = row.get('tbl_key')
# Skip user's own strategies
try:
if user_id is not None and int(creator) == int(user_id):
continue
except (ValueError, TypeError):
pass
strat = self._redact_strategy_internals(row.to_dict())
strat['creator_name'] = self._get_username_for_id(creator)
strat['is_subscribed'] = self.is_subscribed(user_id, tbl_key)
result.append(strat)
return result
def _check_unique_public_name(self, name: str, exclude_tbl_key: str = None) -> bool:
"""
Check if a public strategy name is unique.
:param name: The strategy name to check.
:param exclude_tbl_key: Optional tbl_key to exclude (for edit operations).
:return: True if the name is unique among public strategies.
"""
public_with_name = self.data_cache.get_rows_from_datacache(
cache_name='strategies',
filter_vals=[('public', 1), ('name', name)],
include_tbl_key=True
)
if public_with_name is None or public_with_name.empty:
return True # Name is unique
# Check all matches, not just first
for _, row in public_with_name.iterrows():
if row.get('tbl_key') != exclude_tbl_key:
return False # Found another public strategy with same name
return True
def delete_strategy(self, tbl_key: str, user_id: int = None) -> dict:
"""
Deletes a strategy identified by its tbl_key.
Security: If user_id is provided, ownership is verified before deletion.
Also removes all subscriptions to this strategy (explicit cleanup, not FK cascade).
:param tbl_key: The unique identifier of the strategy to delete.
:param user_id: The ID of the user requesting deletion (for ownership check).
:return: A dictionary indicating success or failure with an appropriate message.
"""
# Ownership check if user_id is provided
if user_id is not None:
if not self.verify_ownership(user_id, tbl_key):
logger.warning(f"User {user_id} attempted to delete strategy '{tbl_key}' without ownership")
return {"success": False, "message": "You don't own this strategy."}
try:
# Remove all subscriptions first (explicit cleanup, don't rely on FK cascade)
self._remove_all_subscriptions_for_strategy(tbl_key)
# Then delete the strategy
self.data_cache.remove_row_from_datacache(
cache_name='strategies',
filter_vals=[('tbl_key', tbl_key)]

View File

@ -15,7 +15,7 @@ logger = logging.getLogger(__name__)
class StrategyInstance:
def __init__(self, strategy_instance_id: str, strategy_id: str, strategy_name: str,
user_id: int, generated_code: str, data_cache: Any, indicators: Any | None, trades: Any | None,
edm_client: Any = None):
edm_client: Any = None, indicator_owner_id: int = None):
"""
Initializes a StrategyInstance.
@ -28,6 +28,8 @@ class StrategyInstance:
:param indicators: Reference to the Indicators manager.
:param trades: Reference to the Trades manager.
:param edm_client: Reference to the EDM client for candle data.
:param indicator_owner_id: For subscribed strategies, the creator's user ID for indicator lookup.
If None, uses user_id.
"""
# Initialize the backtrader_strategy attribute
self.backtrader_strategy = None # Will be set by Backtrader's MappedStrategy
@ -42,6 +44,9 @@ class StrategyInstance:
self.trades = trades
self.edm_client = edm_client
# For subscribed strategies, indicator lookup uses the creator's indicators
self.indicator_owner_id = indicator_owner_id if indicator_owner_id is not None else user_id
# Initialize context variables
self.flags: dict[str, Any] = {}
self.variables: dict[str, Any] = {}
@ -741,17 +746,22 @@ class StrategyInstance:
"""
Retrieves the latest value of an indicator.
For subscribed strategies, indicators are looked up using indicator_owner_id
(the strategy creator's ID) rather than the running user's ID.
:param indicator_name: Name of the indicator.
:param output_field: Specific field of the indicator.
:return: Indicator value.
"""
logger.debug(f"StrategyInstance is Retrieving indicator '{indicator_name}' from Indicators for user '{self.user_id}'.")
# Use indicator_owner_id for lookup (creator's indicators for subscribed strategies)
lookup_user_id = self.indicator_owner_id
logger.debug(f"StrategyInstance is Retrieving indicator '{indicator_name}' from Indicators for user '{lookup_user_id}'.")
try:
user_indicators = self.indicators.get_indicator_list(user_id=self.user_id)
user_indicators = self.indicators.get_indicator_list(user_id=lookup_user_id)
indicator = user_indicators.get(indicator_name)
if not indicator:
logger.error(f"Indicator '{indicator_name}' not found for user '{self.user_id}'.")
logger.error(f"Indicator '{indicator_name}' not found for user '{lookup_user_id}'.")
return None
indicator_value = self.indicators.process_indicator(indicator)
value = indicator_value.get(output_field, None)

View File

@ -69,6 +69,10 @@ brighter_trades = BrighterTrades(socketio)
# Set server configuration globals.
CORS_HEADERS = 'Content-Type'
# Socket ID to authenticated user_id mapping
# This is the source of truth for WebSocket authentication - never trust client payloads
socket_user_mapping = {} # request.sid -> user_id
# Set the app directly with the globals.
app.config.from_object(__name__)
app.secret_key = '1_BAD_secrete_KEY_is_not_2'
@ -343,27 +347,59 @@ def index():
@socketio.on('connect')
def handle_connect():
user_name = request.args.get('user_name')
if not user_name:
user_name = resolve_user_name({
'userId': request.args.get('userId'),
'user_id': request.args.get('user_id')
})
if user_name and brighter_trades.get_user_info(user_name=user_name, info='Is logged in?'):
# Join a room specific to the user for targeted messaging
room = user_name # You can choose an appropriate room naming strategy
join_room(room)
emit('message', {'reply': 'connected', 'data': 'Connection established'})
else:
emit('message', {'reply': 'error', 'data': 'User not authenticated'})
# Disconnect the client if not authenticated
"""
Handle WebSocket connection.
Security: User identity is determined from Flask session (set during HTTP login),
NOT from query parameters. This prevents identity spoofing.
"""
# Get user from Flask session - this is set during HTTP login (/login route)
session_user = session.get('user')
if not session_user:
# No session user - reject connection
emit('message', {'reply': 'error', 'data': 'User not authenticated - no session'})
disconnect()
return
# Verify user is logged in
if not brighter_trades.get_user_info(user_name=session_user, info='Is logged in?'):
emit('message', {'reply': 'error', 'data': 'User not logged in'})
disconnect()
return
# Get user_id from username
try:
user_id = brighter_trades.get_user_info(user_name=session_user, info='User_id')
except Exception:
emit('message', {'reply': 'error', 'data': 'Could not resolve user identity'})
disconnect()
return
# Store the authenticated user_id for this socket - THIS IS THE SOURCE OF TRUTH
socket_user_mapping[request.sid] = {
'user_id': user_id,
'user_name': session_user
}
# Join a room specific to the user for targeted messaging
join_room(session_user)
emit('message', {'reply': 'connected', 'data': 'Connection established'})
@socketio.on('disconnect')
def handle_disconnect():
"""Clean up socket mapping on disconnect."""
socket_user_mapping.pop(request.sid, None)
@socketio.on('message')
def handle_message(data):
"""
Handle incoming JSON messages with authentication.
Security: User identity is determined from socket_user_mapping (set at connect time),
NOT from message payload. This prevents identity spoofing attacks.
"""
# Validate input
if 'message_type' not in data or 'data' not in data:
@ -372,28 +408,30 @@ def handle_message(data):
msg_type, msg_data = data['message_type'], data['data']
# Extract user_name from the incoming message data
user_name = resolve_user_name(msg_data)
if not user_name:
emit('message', {"success": False, "message": "User not specified"})
# Get authenticated user from our mapping - THIS IS THE SOURCE OF TRUTH
# DO NOT trust msg_data.get('user_name') or msg_data.get('user_id')
auth_info = socket_user_mapping.get(request.sid)
if not auth_info:
emit('message', {"success": False, "message": "Not authenticated"})
return
msg_data.setdefault('user_name', user_name)
try:
user_id = brighter_trades.get_user_info(user_name=user_name, info='User_id')
if user_id is not None:
msg_data.setdefault('user_id', user_id)
msg_data.setdefault('userId', user_id)
except Exception:
pass
# Use server-verified identity, ignoring any identity claims in payload
authenticated_user_id = auth_info['user_id']
authenticated_user_name = auth_info['user_name']
# Check if the user is logged in
if not brighter_trades.get_user_info(user_name=user_name, info='Is logged in?'):
emit('message', {"success": False, "message": "User not logged in"})
return
# Inject authenticated identity into msg_data (overwriting any client-provided values)
msg_data['user_name'] = authenticated_user_name
msg_data['user'] = authenticated_user_name
msg_data['user_id'] = authenticated_user_id
msg_data['userId'] = authenticated_user_id
# Process the incoming message based on the type
resp = brighter_trades.process_incoming_message(msg_type=msg_type, msg_data=msg_data, socket_conn_id=request.sid)
# Process the incoming message with server-verified user identity
resp = brighter_trades.process_incoming_message(
msg_type=msg_type,
msg_data=msg_data,
socket_conn_id=request.sid,
authenticated_user_id=authenticated_user_id
)
# Send the response back to the client
if resp:
@ -479,6 +517,21 @@ def signout():
return redirect('/')
if brighter_trades.log_user_in_out(user_name=user_name, cmd='logout'):
# Disconnect any active WebSocket connections for this user
# to prevent continued access after logout
sids_to_remove = []
for sid, auth_info in list(socket_user_mapping.items()):
if auth_info.get('user_name') == user_name:
sids_to_remove.append(sid)
for sid in sids_to_remove:
socket_user_mapping.pop(sid, None)
try:
# Disconnect the socket (they'll need to re-authenticate)
socketio.server.disconnect(sid)
except Exception:
pass # Socket may already be closed
# If the user was logged out successfully delete the session var.
del session['user']

View File

@ -22,13 +22,14 @@ class BacktestStrategyInstance(StrategyInstance):
def __init__(self, strategy_instance_id: str, strategy_id: str, strategy_name: str,
user_id: int, generated_code: str, data_cache: Any, indicators: Any | None,
trades: Any | None, backtrader_strategy: Optional[bt.Strategy] = None,
edm_client: Any = None):
edm_client: Any = None, indicator_owner_id: int = None):
# Set 'self.broker' and 'self.backtrader_strategy' to None before calling super().__init__()
self.broker = None
self.backtrader_strategy = None
super().__init__(strategy_instance_id, strategy_id, strategy_name, user_id,
generated_code, data_cache, indicators, trades, edm_client)
generated_code, data_cache, indicators, trades, edm_client,
indicator_owner_id=indicator_owner_id)
# Set the backtrader_strategy instance after super().__init__()
self.backtrader_strategy = backtrader_strategy

View File

@ -321,13 +321,19 @@ class Backtester:
logger.error(f"Error preparing data feed: {e}")
return pd.DataFrame()
def precompute_indicators(self, indicators_definitions: list, user_name: str, data_feed: pd.DataFrame) -> dict:
def precompute_indicators(self, indicators_definitions: list, user_name: str, data_feed: pd.DataFrame,
indicator_owner_id: int = None) -> dict:
"""
Precompute indicator values directly on the backtest data feed.
IMPORTANT: This computes indicators on the actual backtest candle data,
ensuring the indicator values align with the price data used in the backtest.
Previously, this fetched fresh/latest candles which caused misalignment.
:param indicators_definitions: List of indicator definitions needed.
:param user_name: The user running the backtest.
:param data_feed: The candle data for backtesting.
:param indicator_owner_id: For subscribed strategies, the creator's user ID for indicator lookup.
"""
import json as json_module # Local import to avoid conflicts
@ -363,6 +369,11 @@ class Backtester:
indicator_outputs[indicator_name] = None # None indicates all outputs
# Get user ID for indicator lookup
# For subscribed strategies, use indicator_owner_id (creator's ID) instead of running user's ID
if indicator_owner_id is not None:
user_id = indicator_owner_id
logger.info(f"[BACKTEST] Using indicator_owner_id {indicator_owner_id} for indicator lookup (subscribed strategy)")
else:
user_id = self.data_cache.get_datacache_item(
item_name='id',
cache_name='users',
@ -442,18 +453,24 @@ class Backtester:
return precomputed_indicators
def _calculate_warmup_period(self, indicators_definitions: list, user_name: str) -> int:
def _calculate_warmup_period(self, indicators_definitions: list, user_name: str, indicator_owner_id: int = None) -> int:
"""
Calculate the maximum warmup period needed based on indicator periods.
:param indicators_definitions: List of indicator definitions from strategy
:param user_name: Username for looking up indicator configs
:param indicator_owner_id: For subscribed strategies, the creator's user ID for indicator lookup.
:return: Maximum warmup period in candles
"""
import json as json_module
max_period = 0
# For subscribed strategies, use indicator_owner_id (creator's ID) instead of running user's ID
if indicator_owner_id is not None:
user_id = indicator_owner_id
logger.info(f"[BACKTEST] Using indicator_owner_id {indicator_owner_id} for warmup calculation (subscribed strategy)")
else:
user_id = self.data_cache.get_datacache_item(
item_name='id',
cache_name='users',
@ -498,11 +515,12 @@ class Backtester:
}
return timeframe_map.get(timeframe.lower(), 60) # Default to 1h
def prepare_backtest_data(self, msg_data: dict, strategy_components: dict) -> tuple:
def prepare_backtest_data(self, msg_data: dict, strategy_components: dict, indicator_owner_id: int = None) -> tuple:
"""
Prepare the data feed and precomputed indicators for backtesting.
:param msg_data: Message data containing backtest parameters.
:param strategy_components: Components of the user-defined strategy.
:param indicator_owner_id: For subscribed strategies, the creator's user ID for indicator lookup.
:return: Tuple of (data_feed, precomputed_indicators).
:raises ValueError: If data sources are invalid or data feed cannot be prepared.
"""
@ -524,7 +542,7 @@ class Backtester:
# Calculate warmup period needed for indicators
indicators_definitions = strategy_components.get('indicators', [])
warmup_candles = self._calculate_warmup_period(indicators_definitions, user_name)
warmup_candles = self._calculate_warmup_period(indicators_definitions, user_name, indicator_owner_id=indicator_owner_id)
# Get timeframe to calculate how far back to fetch for warmup
timeframe = main_source.get('timeframe', '1h')
@ -549,7 +567,9 @@ class Backtester:
raise ValueError("Data feed could not be prepared. Please check the data source.")
# Precompute indicator values on the full dataset (including warmup candles)
precomputed_indicators = self.precompute_indicators(indicators_definitions, user_name, data_feed)
precomputed_indicators = self.precompute_indicators(
indicators_definitions, user_name, data_feed, indicator_owner_id=indicator_owner_id
)
# Now trim BOTH the data feed AND indicators to start at the user's original start_date
# This ensures the first indicator values in the backtest have full warmup context
@ -740,6 +760,27 @@ class Backtester:
tbl_key = msg_data.get('strategy') # Expecting tbl_key instead of strategy_name
backtest_name = msg_data.get('backtest_name') # Use the client-provided backtest_name
# Authorization check: user must own the strategy OR be subscribed to it
strategy = self.strategies.get_strategy_by_tbl_key(tbl_key)
if not strategy:
return {"error": "Strategy not found."}
strategy_creator = strategy.get('creator')
try:
creator_id = int(strategy_creator) if strategy_creator is not None else None
except (ValueError, TypeError):
creator_id = None
is_owner = (creator_id == user_id) if (creator_id is not None and user_id is not None) else False
is_subscribed = self.strategies.is_subscribed(user_id, tbl_key)
# Must be owner OR subscribed to run backtest
if not is_owner and not is_subscribed:
return {"error": "Subscribe to this strategy first"}
# For subscribed strategies, use creator's indicators
indicator_owner_id = creator_id if is_subscribed and not is_owner else None
if not backtest_name:
# If backtest_name is not provided, generate a unique name
backtest_name = f"{tbl_key}_backtest"
@ -777,7 +818,9 @@ class Backtester:
msg_data['trading_source'] = source
try:
data_feed, precomputed_indicators = self.prepare_backtest_data(msg_data, strategy_components)
data_feed, precomputed_indicators = self.prepare_backtest_data(
msg_data, strategy_components, indicator_owner_id=indicator_owner_id
)
except ValueError as ve:
logger.error(f"Error preparing backtest data: {ve}")
return {"error": str(ve)}
@ -791,7 +834,8 @@ class Backtester:
generated_code=strategy_components.get("generated_code", ""),
data_cache=self.data_cache,
indicators=None, # Custom handling in BacktestStrategyInstance
trades=None # Custom handling in BacktestStrategyInstance
trades=None, # Custom handling in BacktestStrategyInstance
indicator_owner_id=indicator_owner_id, # For subscribed strategies, use creator's indicators
)
# Cache the backtest

View File

@ -47,6 +47,7 @@ class LiveStrategyInstance(StrategyInstance):
circuit_breaker_pct: float = -0.10,
rate_limit: float = 2.0,
edm_client: Any = None,
indicator_owner_id: int = None,
):
"""
Initialize the LiveStrategyInstance.
@ -67,6 +68,7 @@ class LiveStrategyInstance(StrategyInstance):
:param max_position_pct: Maximum position size as percentage of balance (0.5 = 50%).
:param circuit_breaker_pct: Drawdown percentage to trigger circuit breaker (-0.10 = -10%).
:param rate_limit: API calls per second limit.
:param indicator_owner_id: For subscribed strategies, the creator's user ID for indicator lookup.
"""
# Safety checks
if not testnet:
@ -102,7 +104,8 @@ class LiveStrategyInstance(StrategyInstance):
# Initialize parent (will call _initialize_or_load_context)
super().__init__(
strategy_instance_id, strategy_id, strategy_name, user_id,
generated_code, data_cache, indicators, trades, edm_client
generated_code, data_cache, indicators, trades, edm_client,
indicator_owner_id=indicator_owner_id
)
# Connect to exchange and sync state

View File

@ -38,6 +38,7 @@ class PaperStrategyInstance(StrategyInstance):
slippage: float = 0.0005,
price_provider: Any = None,
edm_client: Any = None,
indicator_owner_id: int = None,
):
"""
Initialize the PaperStrategyInstance.
@ -54,6 +55,7 @@ class PaperStrategyInstance(StrategyInstance):
:param commission: Commission rate for paper trades.
:param slippage: Slippage rate for market orders.
:param price_provider: Callable to get current prices.
:param indicator_owner_id: For subscribed strategies, the creator's user ID for indicator lookup.
"""
# Initialize the paper broker
self.paper_broker = PaperBroker(
@ -69,7 +71,8 @@ class PaperStrategyInstance(StrategyInstance):
super().__init__(
strategy_instance_id, strategy_id, strategy_name, user_id,
generated_code, data_cache, indicators, trades, edm_client
generated_code, data_cache, indicators, trades, edm_client,
indicator_owner_id=indicator_owner_id
)
# Initialize balance attributes from paper broker

View File

@ -1,3 +1,32 @@
/**
* Escapes HTML special characters to prevent XSS attacks.
* @param {string} str - The string to escape.
* @returns {string} - The escaped string.
*/
function escapeHtml(str) {
if (str == null) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
/**
* Escapes a string for safe embedding inside a single-quoted JS string literal.
* @param {string} str - Raw string value.
* @returns {string} - JS-escaped string.
*/
function escapeJsString(str) {
if (str == null) return '';
return String(str)
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'")
.replace(/\r/g, '\\r')
.replace(/\n/g, '\\n');
}
class StratUIManager {
constructor(workspaceManager) {
this.workspaceManager = workspaceManager;
@ -341,11 +370,39 @@ class StratUIManager {
strategyItem.className = 'strategy-item';
strategyItem.setAttribute('data-strategy-id', strat.tbl_key);
// Check if this is a subscribed strategy (not owned)
const isSubscribed = strat.is_subscribed && !strat.is_owner;
const isOwner = strat.is_owner !== false; // Default to owner if not specified
// Check if strategy is running
const isRunning = UI.strats && UI.strats.isStrategyRunning(strat.tbl_key);
const runningInfo = isRunning ? UI.strats.getRunningInfo(strat.tbl_key) : null;
// Delete button
// Add subscribed class if applicable
if (isSubscribed) {
strategyItem.classList.add('subscribed');
}
// Delete/Unsubscribe button
if (isSubscribed) {
// Show unsubscribe button for subscribed strategies
const unsubscribeButton = document.createElement('button');
unsubscribeButton.className = 'unsubscribe-button';
unsubscribeButton.innerHTML = '&#8722;'; // Minus sign
unsubscribeButton.title = 'Unsubscribe from strategy';
unsubscribeButton.addEventListener('click', (e) => {
e.stopPropagation();
if (isRunning) {
alert('Cannot unsubscribe while strategy is running. Stop it first.');
return;
}
if (UI.strats && UI.strats.unsubscribeFromStrategy) {
UI.strats.unsubscribeFromStrategy(strat.tbl_key);
}
});
strategyItem.appendChild(unsubscribeButton);
} else {
// Delete button for owned strategies
const deleteButton = document.createElement('button');
deleteButton.className = 'delete-button';
deleteButton.innerHTML = '&#10008;';
@ -363,6 +420,7 @@ class StratUIManager {
}
});
strategyItem.appendChild(deleteButton);
}
// Run/Stop button
const runButton = document.createElement('button');
@ -383,11 +441,20 @@ class StratUIManager {
// Strategy icon
const strategyIcon = document.createElement('div');
strategyIcon.className = isRunning ? 'strategy-icon running' : 'strategy-icon';
if (isSubscribed) {
strategyIcon.classList.add('subscribed');
}
strategyIcon.addEventListener('click', () => {
console.log(`Strategy icon clicked for strategy: ${strat.name}`);
if (isSubscribed) {
// Show info modal for subscribed strategies (can't edit)
this.showSubscribedStrategyInfo(strat);
} else {
// Normal edit behavior for owned strategies
this.displayForm('edit', strat).catch(error => {
console.error('Error displaying form:', error);
});
}
});
// Strategy name
@ -395,18 +462,31 @@ class StratUIManager {
strategyName.className = 'strategy-name';
strategyName.textContent = strat.name || 'Unnamed Strategy';
strategyIcon.appendChild(strategyName);
// Creator badge for subscribed strategies
if (isSubscribed && strat.creator_name) {
const creatorBadge = document.createElement('div');
creatorBadge.className = 'creator-badge';
creatorBadge.textContent = `by @${strat.creator_name}`;
strategyIcon.appendChild(creatorBadge);
}
strategyItem.appendChild(strategyIcon);
// Strategy hover details with run controls
const strategyHover = document.createElement('div');
strategyHover.className = 'strategy-hover';
const strategyKey = String(strat.tbl_key || '');
const strategyKeyHtml = escapeHtml(strategyKey);
const strategyKeyJs = escapeHtml(escapeJsString(strategyKey));
// Build hover content
let hoverHtml = `<strong>${strat.name || 'Unnamed Strategy'}</strong>`;
// Build hover content (escape user-controlled values)
let hoverHtml = `<strong>${escapeHtml(strat.name || 'Unnamed Strategy')}</strong>`;
// Show running status if applicable
if (isRunning) {
let modeDisplay = runningInfo.mode;
const safeModeDisplay = escapeHtml(modeDisplay);
let modeBadge = '';
// Add testnet/production badge for live mode
@ -420,7 +500,7 @@ class StratUIManager {
let statusHtml = `
<div class="strategy-status running">
Running in <strong>${modeDisplay}</strong> mode ${modeBadge}`;
Running in <strong>${safeModeDisplay}</strong> mode ${modeBadge}`;
// Show balance if available
if (runningInfo.balance !== undefined) {
@ -432,7 +512,8 @@ class StratUIManager {
// Show circuit breaker status for live mode
if (runningInfo.circuit_breaker && runningInfo.circuit_breaker.tripped) {
statusHtml += `<br><span style="color: #dc3545;">⚠️ Circuit Breaker TRIPPED: ${runningInfo.circuit_breaker.reason}</span>`;
const safeCircuitReason = escapeHtml(runningInfo.circuit_breaker.reason || 'Unknown');
statusHtml += `<br><span style="color: #dc3545;">⚠️ Circuit Breaker TRIPPED: ${safeCircuitReason}</span>`;
}
statusHtml += `</div>`;
@ -441,20 +522,20 @@ class StratUIManager {
// Stats
if (strat.stats && Object.keys(strat.stats).length > 0) {
hoverHtml += `<br><small>Stats: ${JSON.stringify(strat.stats, null, 2)}</small>`;
hoverHtml += `<br><small>Stats: ${escapeHtml(JSON.stringify(strat.stats, null, 2))}</small>`;
}
// Run controls
hoverHtml += `
<div class="strategy-controls">
<select id="mode-select-${strat.tbl_key}" ${isRunning ? 'disabled' : ''}
onchange="UI.strats.onModeChange('${strat.tbl_key}', this.value)">
<select id="mode-select-${strategyKeyHtml}" ${isRunning ? 'disabled' : ''}
onchange="UI.strats.onModeChange('${strategyKeyJs}', this.value)">
<option value="paper" ${runningInfo?.mode === 'paper' ? 'selected' : ''}>Paper Trading</option>
<option value="live" ${runningInfo?.mode === 'live' ? 'selected' : ''}>Live Trading</option>
</select>
<div id="live-options-${strat.tbl_key}" style="display: none; margin-top: 5px;">
<div id="live-options-${strategyKeyHtml}" style="display: none; margin-top: 5px;">
<label style="font-size: 10px; display: block;">
<input type="checkbox" id="testnet-${strat.tbl_key}" checked>
<input type="checkbox" id="testnet-${strategyKeyHtml}" checked>
Testnet Mode (Recommended)
</label>
<small style="color: #ff6600; font-size: 9px; display: block; margin-top: 3px;">
@ -463,8 +544,8 @@ class StratUIManager {
</div>
<button class="btn-run ${isRunning ? 'running' : ''}"
onclick="event.stopPropagation(); ${isRunning
? `UI.strats.stopStrategy('${strat.tbl_key}')`
: `UI.strats.runStrategyWithOptions('${strat.tbl_key}')`
? `UI.strats.stopStrategy('${strategyKeyJs}')`
: `UI.strats.runStrategyWithOptions('${strategyKeyJs}')`
}">
${isRunning ? 'Stop Strategy' : 'Run Strategy'}
</button>
@ -675,6 +756,87 @@ class StratUIManager {
timeframe: timeframeEl ? timeframeEl.value : '5m'
};
}
/**
* Shows information modal for subscribed strategies (cannot edit).
* @param {Object} strat - The subscribed strategy object.
*/
showSubscribedStrategyInfo(strat) {
const message = `Strategy: ${strat.name}\nCreator: @${strat.creator_name || 'Unknown'}\n\nThis is a subscribed strategy and cannot be edited.\n\nYou can run this strategy but the workspace and code are not accessible.`;
alert(message);
}
/**
* Shows the public strategy browser modal.
*/
async showPublicStrategyBrowser() {
// Request public strategies from server
if (UI.strats && UI.strats.requestPublicStrategies) {
UI.strats.requestPublicStrategies();
}
}
/**
* Renders the public strategy browser modal with available strategies.
* @param {Array} strategies - List of public strategies to display.
*/
renderPublicStrategyModal(strategies) {
// Remove existing modal if any
let existingModal = document.getElementById('public-strategy-modal');
if (existingModal) {
existingModal.remove();
}
// Create modal
const modal = document.createElement('div');
modal.id = 'public-strategy-modal';
modal.className = 'modal-overlay';
modal.innerHTML = `
<div class="modal-content public-strategy-browser">
<div class="modal-header">
<h2>Public Strategies</h2>
<button class="modal-close" onclick="this.closest('.modal-overlay').remove()">&times;</button>
</div>
<div class="modal-body">
<div class="public-strategies-list">
${strategies.length === 0
? '<p class="no-strategies">No public strategies available.</p>'
: strategies.map(s => {
const keyRaw = String(s.tbl_key || '');
const keyHtml = escapeHtml(keyRaw);
const keyJs = escapeHtml(escapeJsString(keyRaw));
const nameHtml = escapeHtml(s.name || 'Unnamed Strategy');
const creatorHtml = escapeHtml(s.creator_name || 'Unknown');
const action = s.is_subscribed ? 'unsubscribeFromStrategy' : 'subscribeToStrategy';
const label = s.is_subscribed ? 'Unsubscribe' : 'Subscribe';
return `
<div class="public-strategy-item ${s.is_subscribed ? 'subscribed' : ''}" data-tbl-key="${keyHtml}">
<div class="strategy-info">
<strong>${nameHtml}</strong>
<span class="creator">by @${creatorHtml}</span>
</div>
<button class="subscribe-btn ${s.is_subscribed ? 'subscribed' : ''}"
onclick="UI.strats.${action}('${keyJs}')">
${label}
</button>
</div>
`;
}).join('')
}
</div>
</div>
</div>
`;
document.body.appendChild(modal);
// Close on overlay click
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.remove();
}
});
}
}
class StratDataManager {
@ -1226,6 +1388,12 @@ class Strategies {
this.comms.on('strategy_status', this.handleStrategyStatus.bind(this));
this.comms.on('strategy_events', this.handleStrategyEvents.bind(this));
// Register handlers for subscription events
this.comms.on('public_strategies', this.handlePublicStrategies.bind(this));
this.comms.on('strategy_subscribed', this.handleStrategySubscribed.bind(this));
this.comms.on('strategy_unsubscribed', this.handleStrategyUnsubscribed.bind(this));
this.comms.on('subscription_error', this.handleSubscriptionError.bind(this));
// Fetch saved strategies using DataManager
this.dataManager.fetchSavedStrategies(this.comms, this.data);
@ -2022,4 +2190,117 @@ class Strategies {
async generateWithAI() {
await this.uiManager.generateWithAI();
}
// ========== Public Strategy Subscription Methods ==========
/**
* Requests list of public strategies from the server.
*/
requestPublicStrategies() {
if (this.comms) {
this.comms.sendToApp('get_public_strategies', {});
}
}
/**
* Subscribes to a public strategy.
* @param {string} tbl_key - The strategy's tbl_key.
*/
subscribeToStrategy(tbl_key) {
if (!tbl_key) {
console.error('subscribeToStrategy: No tbl_key provided');
return;
}
if (this.comms) {
this.comms.sendToApp('subscribe_strategy', { strategy_tbl_key: tbl_key });
}
}
/**
* Unsubscribes from a strategy.
* @param {string} tbl_key - The strategy's tbl_key.
*/
unsubscribeFromStrategy(tbl_key) {
if (!tbl_key) {
console.error('unsubscribeFromStrategy: No tbl_key provided');
return;
}
// Check if strategy is running (in any mode)
if (this.isStrategyRunning(tbl_key)) {
alert('Cannot unsubscribe while strategy is running. Stop it first.');
return;
}
if (!confirm('Unsubscribe from this strategy?')) {
return;
}
if (this.comms) {
this.comms.sendToApp('unsubscribe_strategy', { strategy_tbl_key: tbl_key });
}
}
/**
* Handles public strategies list from server.
* @param {Object} data - Response containing strategies array.
*/
handlePublicStrategies(data) {
console.log('Received public strategies:', data);
if (data && data.strategies) {
this.uiManager.renderPublicStrategyModal(data.strategies);
}
}
/**
* Handles successful subscription response.
* @param {Object} data - Response containing success info.
*/
handleStrategySubscribed(data) {
console.log('Strategy subscribed:', data);
if (data && data.success) {
// Refresh strategy list to include new subscription
this.dataManager.fetchSavedStrategies(this.comms, this.data);
// Close the public strategy browser modal if open
const modal = document.getElementById('public-strategy-modal');
if (modal) {
modal.remove();
}
// Show success feedback
if (data.strategy_name) {
alert(`Successfully subscribed to "${data.strategy_name}"`);
}
}
}
/**
* Handles successful unsubscription response.
* @param {Object} data - Response containing success info.
*/
handleStrategyUnsubscribed(data) {
console.log('Strategy unsubscribed:', data);
if (data && data.success) {
// Refresh strategy list to remove unsubscribed strategy
this.dataManager.fetchSavedStrategies(this.comms, this.data);
// Update public strategy browser if open
const modal = document.getElementById('public-strategy-modal');
if (modal) {
// Refresh the modal content
this.requestPublicStrategies();
}
}
}
/**
* Handles subscription error response.
* @param {Object} data - Response containing error info.
*/
handleSubscriptionError(data) {
console.error('Subscription error:', data);
const message = data && data.message ? data.message : 'Subscription operation failed';
alert(message);
}
}

View File

@ -1,5 +1,6 @@
<div class="content" id="strats_content">
<button class="btn" id="new_strats_btn" onclick="UI.strats.uiManager.displayForm('new')">New Strategy</button>
<button class="btn" id="browse_public_btn" onclick="UI.strats.uiManager.showPublicStrategyBrowser()">+ Add Public</button>
<hr>
<h3>Strategies</h3>
<div class="strategies-container" id="strats_display"></div>
@ -199,4 +200,185 @@
100% { box-shadow: 0 0 0 0 rgba(40, 167, 69, 0); }
}
/* Subscribed strategy styling */
.strategy-item.subscribed .strategy-icon {
border: 2px solid #17a2b8;
}
.strategy-item.subscribed .delete-button {
display: none;
}
.strategy-icon.subscribed {
border: 2px solid #17a2b8;
}
/* Unsubscribe button (replaces delete for subscribed) */
.unsubscribe-button {
z-index: 20;
position: absolute;
top: 5px;
left: 5px;
background-color: #17a2b8;
color: white;
border: none;
border-radius: 50%;
width: 20px;
height: 20px;
font-size: 14px;
cursor: pointer;
transition: transform 0.3s ease, background-color 0.3s ease, box-shadow 0.3s ease;
}
.unsubscribe-button:hover {
transform: scale(1.2);
background-color: #138496;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
/* Creator badge for subscribed strategies */
.creator-badge {
position: absolute;
bottom: 22px;
left: 50%;
transform: translateX(-50%);
background-color: #17a2b8;
color: white;
padding: 2px 6px;
border-radius: 10px;
font-size: 9px;
white-space: nowrap;
max-width: 90px;
overflow: hidden;
text-overflow: ellipsis;
}
/* Modal overlay styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background-color: white;
border-radius: 8px;
padding: 0;
max-height: 80vh;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
border-bottom: 1px solid #ddd;
}
.modal-header h2 {
margin: 0;
font-size: 18px;
}
.modal-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #666;
padding: 0;
line-height: 1;
}
.modal-close:hover {
color: #333;
}
.modal-body {
padding: 20px;
overflow-y: auto;
max-height: calc(80vh - 60px);
}
/* Public Strategy Browser Modal */
.public-strategy-browser {
max-width: 600px;
width: 90%;
}
.public-strategies-list {
display: flex;
flex-direction: column;
gap: 10px;
max-height: 400px;
overflow-y: auto;
}
.public-strategy-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
background-color: #f8f9fa;
border-radius: 6px;
border: 1px solid #dee2e6;
}
.public-strategy-item.subscribed {
border-color: #17a2b8;
background-color: #e7f6f8;
}
.public-strategy-item .strategy-info {
flex: 1;
}
.public-strategy-item .strategy-info strong {
display: block;
margin-bottom: 3px;
}
.public-strategy-item .strategy-info .creator {
font-size: 11px;
color: #666;
}
.no-strategies {
text-align: center;
color: #666;
padding: 20px;
}
.subscribe-btn {
padding: 6px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
background-color: #28a745;
color: white;
transition: background-color 0.2s;
}
.subscribe-btn:hover {
background-color: #218838;
}
.subscribe-btn.subscribed {
background-color: #dc3545;
}
.subscribe-btn.subscribed:hover {
background-color: #c82333;
}
</style>

View File

@ -75,24 +75,21 @@ class TestStartStrategyValidation:
assert 'not found' in result['message']
def test_start_strategy_authorization_check(self, mock_brighter_trades):
"""Test that non-owner cannot run private strategy."""
"""Test that non-owner, non-subscriber cannot run private strategy."""
import pandas as pd
# Mock strategy owned by different user
# Mock strategy owned by different user (creator is user_id 2, not 1)
mock_strategy = pd.DataFrame([{
'tbl_key': 'test-strategy',
'name': 'Test Strategy',
'creator': 'other_user',
'creator': 2, # Different user_id
'public': False,
'strategy_components': json.dumps({'generated_code': 'pass'})
}])
mock_brighter_trades.strategies.data_cache.get_rows_from_datacache.return_value = mock_strategy
# Mock that requesting user is different
mock_brighter_trades.get_user_info = MagicMock(side_effect=lambda **kwargs: {
'user_name': 'test_user',
'User_id': 2 # Different user
}.get(kwargs.get('info')))
# Mock that user is NOT subscribed
mock_brighter_trades.strategies.is_subscribed = MagicMock(return_value=False)
result = mock_brighter_trades.start_strategy(
user_id=1,
@ -101,29 +98,25 @@ class TestStartStrategyValidation:
)
assert result['success'] is False
assert 'permission' in result['message'].lower()
assert 'subscribe' in result['message'].lower()
def test_start_strategy_authorization_does_not_call_get_user_info_with_user_id_kwarg(self, mock_brighter_trades):
def test_start_strategy_authorization_non_subscriber_denied(self, mock_brighter_trades):
"""
Regression test: get_user_info should be called with (user_name, info) only.
Test that non-owner, non-subscriber cannot run strategy (even private).
"""
import pandas as pd
mock_strategy = pd.DataFrame([{
'tbl_key': 'test-strategy',
'name': 'Test Strategy',
'creator': 'other_user',
'creator': 2, # Different user_id
'public': False,
'strategy_components': json.dumps({'generated_code': 'pass'})
}])
mock_brighter_trades.strategies.data_cache.get_rows_from_datacache.return_value = mock_strategy
def strict_get_user_info(user_name, info):
if info == 'User_id' and user_name == 'other_user':
return 2
return None
mock_brighter_trades.get_user_info = MagicMock(side_effect=strict_get_user_info)
# Mock that user is NOT subscribed
mock_brighter_trades.strategies.is_subscribed = MagicMock(return_value=False)
result = mock_brighter_trades.start_strategy(
user_id=1,
@ -132,16 +125,17 @@ class TestStartStrategyValidation:
)
assert result['success'] is False
assert 'permission' in result['message'].lower()
assert 'subscribe' in result['message'].lower()
def test_start_strategy_live_mode_uses_live_active_instance_key(self, mock_brighter_trades):
"""Live mode now runs in actual live mode with proper instance keying."""
import pandas as pd
# Strategy owned by the running user (no subscription needed)
mock_strategy = pd.DataFrame([{
'tbl_key': 'test-strategy',
'name': 'Test Strategy',
'creator': 'other_user',
'creator': 'test_user', # Same as mock user (user_id=1)
'public': True,
'strategy_components': json.dumps({'generated_code': 'pass'})
}])
@ -161,8 +155,8 @@ class TestStartStrategyValidation:
assert result['actual_mode'] == 'live'
assert (1, 'test-strategy', 'live') in mock_brighter_trades.strategies.active_instances
def test_start_strategy_public_strategy_allowed(self, mock_brighter_trades):
"""Test that anyone can run a public strategy."""
def test_start_strategy_subscribed_strategy_allowed(self, mock_brighter_trades):
"""Test that a subscribed user can run a public strategy."""
import pandas as pd
# Mock public strategy owned by different user
@ -178,6 +172,8 @@ class TestStartStrategyValidation:
mock_brighter_trades.strategies.create_strategy_instance.return_value = MagicMock(
strategy_name='Public Strategy'
)
# Mock the subscription check to return True
mock_brighter_trades.strategies.is_subscribed = MagicMock(return_value=True)
result = mock_brighter_trades.start_strategy(
user_id=1,
@ -187,6 +183,31 @@ class TestStartStrategyValidation:
assert result['success'] is True
def test_start_strategy_unsubscribed_public_strategy_denied(self, mock_brighter_trades):
"""Test that unsubscribed user cannot run a public strategy they don't own."""
import pandas as pd
# Mock public strategy owned by different user
mock_strategy = pd.DataFrame([{
'tbl_key': 'test-strategy',
'name': 'Public Strategy',
'creator': 'other_user',
'public': True,
'strategy_components': json.dumps({'generated_code': 'pass'})
}])
mock_brighter_trades.strategies.data_cache.get_rows_from_datacache.return_value = mock_strategy
# Mock the subscription check to return False
mock_brighter_trades.strategies.is_subscribed = MagicMock(return_value=False)
result = mock_brighter_trades.start_strategy(
user_id=1,
strategy_id='test-strategy',
mode='paper'
)
assert result['success'] is False
assert 'subscribe' in result['message'].lower()
def test_start_strategy_already_running(self, mock_brighter_trades):
"""Test that strategy cannot be started twice in same mode."""
import pandas as pd