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) logger.error(f"Error editing strategy: {e}", exc_info=True)
return {"success": False, "message": "An unexpected error occurred while editing the strategy"} 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. 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. :return: A dictionary indicating success or failure with an appropriate message and the tbl_key.
""" """
# Validate tbl_key # Validate tbl_key
@ -657,8 +660,12 @@ class BrighterTrades:
if not tbl_key: if not tbl_key:
return {"success": False, "message": "tbl_key not provided", "tbl_key": None} return {"success": False, "message": "tbl_key not provided", "tbl_key": None}
# Call the delete_strategy method to remove the strategy # Get user_id from data if not passed directly (backwards compatibility)
result = self.strategies.delete_strategy(tbl_key=tbl_key) 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 # Return the result with tbl_key included
if result.get('success'): if result.get('success'):
@ -724,41 +731,26 @@ class BrighterTrades:
strategy_row = strategy_data.iloc[0] strategy_row = strategy_data.iloc[0]
strategy_name = strategy_row.get('name', 'Unknown') 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') strategy_creator = strategy_row.get('creator')
is_public = bool(strategy_row.get('public', False)) try:
creator_id = int(strategy_creator) if strategy_creator is not None else None
except (ValueError, TypeError):
creator_id = None
if not is_public: is_owner = (creator_id == user_id) if (creator_id is not None and user_id is not None) else False
requester_name = None is_subscribed = self.strategies.is_subscribed(user_id, strategy_id)
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_str = str(strategy_creator) if strategy_creator is not None else '' # Must be owner OR subscribed to run
requester_id_str = str(user_id) if not is_owner and not is_subscribed:
return {
"success": False,
"message": "Subscribe to this strategy first"
}
creator_matches_user = False # For subscribed strategies, use creator's indicators
if creator_str: # This ensures subscribers run with the creator's indicator definitions
# Support creator being stored as user_name or user_id. indicator_owner_id = creator_id if is_subscribed and not is_owner else None
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:
return {
"success": False,
"message": "You do not have permission to run this strategy."
}
# Check if already running # Check if already running
instance_key = (user_id, strategy_id, effective_mode) instance_key = (user_id, strategy_id, effective_mode)
@ -917,6 +909,7 @@ class BrighterTrades:
testnet=actual_testnet, testnet=actual_testnet,
max_position_pct=max_position_pct, max_position_pct=max_position_pct,
circuit_breaker_pct=circuit_breaker_pct, circuit_breaker_pct=circuit_breaker_pct,
indicator_owner_id=indicator_owner_id, # For subscribed strategies, use creator's indicators
) )
# Store the active instance # Store the active instance
@ -1115,11 +1108,14 @@ class BrighterTrades:
def get_strategies_json(self, user_id) -> list: 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: 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: def connect_or_config_exchange(self, user_name: str, exchange_name: str, api_keys: dict = None) -> dict:
""" """
@ -1427,14 +1423,21 @@ class BrighterTrades:
return 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. 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_type: The type of the incoming message.
:param msg_data: The data associated with 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 :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 no data is found to ensure the WebSocket channel isn't burdened with unnecessary
communication. communication.
@ -1447,8 +1450,14 @@ class BrighterTrades:
""" Formats a standard reply message. """ """ Formats a standard reply message. """
return {"reply": reply_msg, "data": reply_data} return {"reply": reply_msg, "data": reply_data}
user_name = self.resolve_user_name(msg_data) # Use authenticated_user_id if provided (from secure socket mapping)
user_id = self.resolve_user_id(msg_data, user_name=user_name) # 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)
if user_name: if user_name:
msg_data.setdefault('user_name', user_name) msg_data.setdefault('user_name', user_name)
@ -1470,8 +1479,9 @@ class BrighterTrades:
elif request_for == 'strategies': elif request_for == 'strategies':
if user_id is None: if user_id is None:
return standard_reply("strategy_error", {"message": "User not specified"}) return standard_reply("strategy_error", {"message": "User not specified"})
if strategies := self.get_strategies_json(user_id): # Always return response, even if empty list
return standard_reply("strategies", strategies) strategies = self.get_strategies_json(user_id)
return standard_reply("strategies", strategies or [])
elif request_for == 'trades': elif request_for == 'trades':
trades = self.get_trades(user_id) trades = self.get_trades(user_id)
@ -1496,7 +1506,8 @@ class BrighterTrades:
}) })
if msg_type == 'delete_strategy': 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'): if result.get('success'):
return standard_reply("strategy_deleted", { return standard_reply("strategy_deleted", {
"message": result.get('message'), "message": result.get('message'),
@ -1716,6 +1727,39 @@ class BrighterTrades:
logger.error(f"Error getting strategy status: {e}", exc_info=True) 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)}"}) 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 msg_type == 'reply':
# If the message is a reply log the response to the terminal. # If the message is a reply log the response to the terminal.
print(f"\napp.py:Received reply: {msg_data}") 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 # Case 1: Retrieve all rows from the cache
if isinstance(cache, RowBasedCache): 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): 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 # Case 2: Fallback to retrieve all rows from the database if cache is empty
return self.db.get_all_rows(cache_name) 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, def _fetch_from_database_with_list_support(self, cache_name: str,
filter_vals: List[tuple[str, Any]]) -> pd.DataFrame: 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 # Initialize default settings
self.default_timeframe = '5m' self.default_timeframe = '5m'
self.default_exchange = 'Binance' self.default_exchange = 'Binance'
@ -97,6 +110,46 @@ class Strategies:
except Exception as e: except Exception as e:
logger.warning(f"Migration check failed (may be expected on fresh install): {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: def update(self, candle_data: dict = None) -> list:
""" """
Update all active strategy instances with new price data. Update all active strategy instances with new price data.
@ -188,6 +241,7 @@ class Strategies:
testnet: bool = True, testnet: bool = True,
max_position_pct: float = 0.5, max_position_pct: float = 0.5,
circuit_breaker_pct: float = -0.10, circuit_breaker_pct: float = -0.10,
indicator_owner_id: int = None,
) -> StrategyInstance: ) -> StrategyInstance:
""" """
Factory method to create the appropriate strategy instance based on mode. 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 testnet: Use testnet for live trading (default True for safety).
:param max_position_pct: Maximum position size as % of balance for live trading. :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 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. :return: Strategy instance appropriate for the mode.
""" """
mode = mode.lower() mode = mode.lower()
@ -226,6 +281,7 @@ class Strategies:
commission=commission, commission=commission,
slippage=slippage if slippage > 0 else 0.0005, slippage=slippage if slippage > 0 else 0.0005,
price_provider=price_provider, price_provider=price_provider,
indicator_owner_id=indicator_owner_id,
) )
elif mode == TradingMode.BACKTEST: elif mode == TradingMode.BACKTEST:
@ -240,6 +296,7 @@ class Strategies:
indicators=self.indicators_manager, indicators=self.indicators_manager,
trades=self.trades, trades=self.trades,
edm_client=self.edm_client, edm_client=self.edm_client,
indicator_owner_id=indicator_owner_id,
) )
elif mode == TradingMode.LIVE: elif mode == TradingMode.LIVE:
@ -280,6 +337,7 @@ class Strategies:
slippage=slippage, slippage=slippage,
max_position_pct=max_position_pct, max_position_pct=max_position_pct,
circuit_breaker_pct=circuit_breaker_pct, circuit_breaker_pct=circuit_breaker_pct,
indicator_owner_id=indicator_owner_id,
) )
else: else:
@ -295,6 +353,7 @@ class Strategies:
indicators=self.indicators_manager, indicators=self.indicators_manager,
trades=self.trades, trades=self.trades,
edm_client=self.edm_client, edm_client=self.edm_client,
indicator_owner_id=indicator_owner_id,
) )
def _save_strategy(self, strategy_data: dict, default_source: dict) -> dict: def _save_strategy(self, strategy_data: dict, default_source: dict) -> dict:
@ -319,6 +378,16 @@ class Strategies:
) )
if existing_strategy.empty: if existing_strategy.empty:
return {"success": False, "message": "Strategy not found."} 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: else:
# Check for duplicate strategy name # Check for duplicate strategy name
filter_conditions = [ filter_conditions = [
@ -333,6 +402,13 @@ class Strategies:
if not existing_strategy.empty: if not existing_strategy.empty:
return {"success": False, "message": "A strategy with this name already exists"} 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' # Validate and serialize 'workspace'
workspace_data = strategy_data.get('workspace') workspace_data = strategy_data.get('workspace')
if not isinstance(workspace_data, str) or not workspace_data.strip(): 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) 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. 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 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. :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: 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( self.data_cache.remove_row_from_datacache(
cache_name='strategies', cache_name='strategies',
filter_vals=[('tbl_key', tbl_key)] filter_vals=[('tbl_key', tbl_key)]

View File

@ -15,7 +15,7 @@ logger = logging.getLogger(__name__)
class StrategyInstance: class StrategyInstance:
def __init__(self, strategy_instance_id: str, strategy_id: str, strategy_name: str, 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, 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. Initializes a StrategyInstance.
@ -28,6 +28,8 @@ class StrategyInstance:
:param indicators: Reference to the Indicators manager. :param indicators: Reference to the Indicators manager.
:param trades: Reference to the Trades manager. :param trades: Reference to the Trades manager.
:param edm_client: Reference to the EDM client for candle data. :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 # Initialize the backtrader_strategy attribute
self.backtrader_strategy = None # Will be set by Backtrader's MappedStrategy self.backtrader_strategy = None # Will be set by Backtrader's MappedStrategy
@ -42,6 +44,9 @@ class StrategyInstance:
self.trades = trades self.trades = trades
self.edm_client = edm_client 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 # Initialize context variables
self.flags: dict[str, Any] = {} self.flags: dict[str, Any] = {}
self.variables: dict[str, Any] = {} self.variables: dict[str, Any] = {}
@ -741,17 +746,22 @@ class StrategyInstance:
""" """
Retrieves the latest value of an indicator. 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 indicator_name: Name of the indicator.
:param output_field: Specific field of the indicator. :param output_field: Specific field of the indicator.
:return: Indicator value. :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: 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) indicator = user_indicators.get(indicator_name)
if not indicator: 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 return None
indicator_value = self.indicators.process_indicator(indicator) indicator_value = self.indicators.process_indicator(indicator)
value = indicator_value.get(output_field, None) value = indicator_value.get(output_field, None)

View File

@ -69,6 +69,10 @@ brighter_trades = BrighterTrades(socketio)
# Set server configuration globals. # Set server configuration globals.
CORS_HEADERS = 'Content-Type' 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. # Set the app directly with the globals.
app.config.from_object(__name__) app.config.from_object(__name__)
app.secret_key = '1_BAD_secrete_KEY_is_not_2' app.secret_key = '1_BAD_secrete_KEY_is_not_2'
@ -343,27 +347,59 @@ def index():
@socketio.on('connect') @socketio.on('connect')
def handle_connect(): def handle_connect():
user_name = request.args.get('user_name') """
if not user_name: Handle WebSocket connection.
user_name = resolve_user_name({
'userId': request.args.get('userId'), Security: User identity is determined from Flask session (set during HTTP login),
'user_id': request.args.get('user_id') NOT from query parameters. This prevents identity spoofing.
}) """
if user_name and brighter_trades.get_user_info(user_name=user_name, info='Is logged in?'): # Get user from Flask session - this is set during HTTP login (/login route)
# Join a room specific to the user for targeted messaging session_user = session.get('user')
room = user_name # You can choose an appropriate room naming strategy
join_room(room) if not session_user:
emit('message', {'reply': 'connected', 'data': 'Connection established'}) # No session user - reject connection
else: emit('message', {'reply': 'error', 'data': 'User not authenticated - no session'})
emit('message', {'reply': 'error', 'data': 'User not authenticated'})
# Disconnect the client if not authenticated
disconnect() 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') @socketio.on('message')
def handle_message(data): def handle_message(data):
""" """
Handle incoming JSON messages with authentication. 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 # Validate input
if 'message_type' not in data or 'data' not in data: 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'] msg_type, msg_data = data['message_type'], data['data']
# Extract user_name from the incoming message data # Get authenticated user from our mapping - THIS IS THE SOURCE OF TRUTH
user_name = resolve_user_name(msg_data) # DO NOT trust msg_data.get('user_name') or msg_data.get('user_id')
if not user_name: auth_info = socket_user_mapping.get(request.sid)
emit('message', {"success": False, "message": "User not specified"}) if not auth_info:
emit('message', {"success": False, "message": "Not authenticated"})
return return
msg_data.setdefault('user_name', user_name) # Use server-verified identity, ignoring any identity claims in payload
try: authenticated_user_id = auth_info['user_id']
user_id = brighter_trades.get_user_info(user_name=user_name, info='User_id') authenticated_user_name = auth_info['user_name']
if user_id is not None:
msg_data.setdefault('user_id', user_id)
msg_data.setdefault('userId', user_id)
except Exception:
pass
# Check if the user is logged in # Inject authenticated identity into msg_data (overwriting any client-provided values)
if not brighter_trades.get_user_info(user_name=user_name, info='Is logged in?'): msg_data['user_name'] = authenticated_user_name
emit('message', {"success": False, "message": "User not logged in"}) msg_data['user'] = authenticated_user_name
return msg_data['user_id'] = authenticated_user_id
msg_data['userId'] = authenticated_user_id
# Process the incoming message based on the type # 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) 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 # Send the response back to the client
if resp: if resp:
@ -479,6 +517,21 @@ def signout():
return redirect('/') return redirect('/')
if brighter_trades.log_user_in_out(user_name=user_name, cmd='logout'): 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. # If the user was logged out successfully delete the session var.
del session['user'] 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, 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, user_id: int, generated_code: str, data_cache: Any, indicators: Any | None,
trades: Any | None, backtrader_strategy: Optional[bt.Strategy] = 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__() # Set 'self.broker' and 'self.backtrader_strategy' to None before calling super().__init__()
self.broker = None self.broker = None
self.backtrader_strategy = None self.backtrader_strategy = None
super().__init__(strategy_instance_id, strategy_id, strategy_name, user_id, 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__() # Set the backtrader_strategy instance after super().__init__()
self.backtrader_strategy = backtrader_strategy self.backtrader_strategy = backtrader_strategy

View File

@ -321,13 +321,19 @@ class Backtester:
logger.error(f"Error preparing data feed: {e}") logger.error(f"Error preparing data feed: {e}")
return pd.DataFrame() 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. Precompute indicator values directly on the backtest data feed.
IMPORTANT: This computes indicators on the actual backtest candle data, IMPORTANT: This computes indicators on the actual backtest candle data,
ensuring the indicator values align with the price data used in the backtest. ensuring the indicator values align with the price data used in the backtest.
Previously, this fetched fresh/latest candles which caused misalignment. 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 import json as json_module # Local import to avoid conflicts
@ -363,11 +369,16 @@ class Backtester:
indicator_outputs[indicator_name] = None # None indicates all outputs indicator_outputs[indicator_name] = None # None indicates all outputs
# Get user ID for indicator lookup # Get user ID for indicator lookup
user_id = self.data_cache.get_datacache_item( # For subscribed strategies, use indicator_owner_id (creator's ID) instead of running user's ID
item_name='id', if indicator_owner_id is not None:
cache_name='users', user_id = indicator_owner_id
filter_vals=('user_name', user_name) 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',
filter_vals=('user_name', user_name)
)
logger.info(f"[BACKTEST] indicator_outputs to precompute: {indicator_outputs}") logger.info(f"[BACKTEST] indicator_outputs to precompute: {indicator_outputs}")
@ -442,23 +453,29 @@ class Backtester:
return precomputed_indicators 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. Calculate the maximum warmup period needed based on indicator periods.
:param indicators_definitions: List of indicator definitions from strategy :param indicators_definitions: List of indicator definitions from strategy
:param user_name: Username for looking up indicator configs :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 :return: Maximum warmup period in candles
""" """
import json as json_module import json as json_module
max_period = 0 max_period = 0
user_id = self.data_cache.get_datacache_item( # For subscribed strategies, use indicator_owner_id (creator's ID) instead of running user's ID
item_name='id', if indicator_owner_id is not None:
cache_name='users', user_id = indicator_owner_id
filter_vals=('user_name', user_name) 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',
filter_vals=('user_name', user_name)
)
for indicator_def in indicators_definitions: for indicator_def in indicators_definitions:
indicator_name = indicator_def.get('name') indicator_name = indicator_def.get('name')
@ -498,11 +515,12 @@ class Backtester:
} }
return timeframe_map.get(timeframe.lower(), 60) # Default to 1h 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. Prepare the data feed and precomputed indicators for backtesting.
:param msg_data: Message data containing backtest parameters. :param msg_data: Message data containing backtest parameters.
:param strategy_components: Components of the user-defined strategy. :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). :return: Tuple of (data_feed, precomputed_indicators).
:raises ValueError: If data sources are invalid or data feed cannot be prepared. :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 # Calculate warmup period needed for indicators
indicators_definitions = strategy_components.get('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 # Get timeframe to calculate how far back to fetch for warmup
timeframe = main_source.get('timeframe', '1h') 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.") raise ValueError("Data feed could not be prepared. Please check the data source.")
# Precompute indicator values on the full dataset (including warmup candles) # 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 # 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 # 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 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 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 not backtest_name:
# If backtest_name is not provided, generate a unique name # If backtest_name is not provided, generate a unique name
backtest_name = f"{tbl_key}_backtest" backtest_name = f"{tbl_key}_backtest"
@ -777,7 +818,9 @@ class Backtester:
msg_data['trading_source'] = source msg_data['trading_source'] = source
try: 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: except ValueError as ve:
logger.error(f"Error preparing backtest data: {ve}") logger.error(f"Error preparing backtest data: {ve}")
return {"error": str(ve)} return {"error": str(ve)}
@ -791,7 +834,8 @@ class Backtester:
generated_code=strategy_components.get("generated_code", ""), generated_code=strategy_components.get("generated_code", ""),
data_cache=self.data_cache, data_cache=self.data_cache,
indicators=None, # Custom handling in BacktestStrategyInstance 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 # Cache the backtest

View File

@ -47,6 +47,7 @@ class LiveStrategyInstance(StrategyInstance):
circuit_breaker_pct: float = -0.10, circuit_breaker_pct: float = -0.10,
rate_limit: float = 2.0, rate_limit: float = 2.0,
edm_client: Any = None, edm_client: Any = None,
indicator_owner_id: int = None,
): ):
""" """
Initialize the LiveStrategyInstance. 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 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 circuit_breaker_pct: Drawdown percentage to trigger circuit breaker (-0.10 = -10%).
:param rate_limit: API calls per second limit. :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 # Safety checks
if not testnet: if not testnet:
@ -102,7 +104,8 @@ class LiveStrategyInstance(StrategyInstance):
# Initialize parent (will call _initialize_or_load_context) # Initialize parent (will call _initialize_or_load_context)
super().__init__( super().__init__(
strategy_instance_id, strategy_id, strategy_name, user_id, 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 # Connect to exchange and sync state

View File

@ -38,6 +38,7 @@ class PaperStrategyInstance(StrategyInstance):
slippage: float = 0.0005, slippage: float = 0.0005,
price_provider: Any = None, price_provider: Any = None,
edm_client: Any = None, edm_client: Any = None,
indicator_owner_id: int = None,
): ):
""" """
Initialize the PaperStrategyInstance. Initialize the PaperStrategyInstance.
@ -54,6 +55,7 @@ class PaperStrategyInstance(StrategyInstance):
:param commission: Commission rate for paper trades. :param commission: Commission rate for paper trades.
:param slippage: Slippage rate for market orders. :param slippage: Slippage rate for market orders.
:param price_provider: Callable to get current prices. :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 # Initialize the paper broker
self.paper_broker = PaperBroker( self.paper_broker = PaperBroker(
@ -69,7 +71,8 @@ class PaperStrategyInstance(StrategyInstance):
super().__init__( super().__init__(
strategy_instance_id, strategy_id, strategy_name, user_id, 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 # 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 { class StratUIManager {
constructor(workspaceManager) { constructor(workspaceManager) {
this.workspaceManager = workspaceManager; this.workspaceManager = workspaceManager;
@ -341,28 +370,57 @@ class StratUIManager {
strategyItem.className = 'strategy-item'; strategyItem.className = 'strategy-item';
strategyItem.setAttribute('data-strategy-id', strat.tbl_key); 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 // Check if strategy is running
const isRunning = UI.strats && UI.strats.isStrategyRunning(strat.tbl_key); const isRunning = UI.strats && UI.strats.isStrategyRunning(strat.tbl_key);
const runningInfo = isRunning ? UI.strats.getRunningInfo(strat.tbl_key) : null; const runningInfo = isRunning ? UI.strats.getRunningInfo(strat.tbl_key) : null;
// Delete button // Add subscribed class if applicable
const deleteButton = document.createElement('button'); if (isSubscribed) {
deleteButton.className = 'delete-button'; strategyItem.classList.add('subscribed');
deleteButton.innerHTML = '&#10008;'; }
deleteButton.addEventListener('click', (e) => {
e.stopPropagation(); // Delete/Unsubscribe button
if (isRunning) { if (isSubscribed) {
alert('Cannot delete a running strategy. Stop it first.'); // Show unsubscribe button for subscribed strategies
return; const unsubscribeButton = document.createElement('button');
} unsubscribeButton.className = 'unsubscribe-button';
console.log(`Delete button clicked for strategy: ${strat.name}`); unsubscribeButton.innerHTML = '&#8722;'; // Minus sign
if (this.onDeleteStrategy) { unsubscribeButton.title = 'Unsubscribe from strategy';
this.onDeleteStrategy(strat.tbl_key); unsubscribeButton.addEventListener('click', (e) => {
} else { e.stopPropagation();
console.error("Delete strategy callback is not set."); if (isRunning) {
} alert('Cannot unsubscribe while strategy is running. Stop it first.');
}); return;
strategyItem.appendChild(deleteButton); }
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;';
deleteButton.addEventListener('click', (e) => {
e.stopPropagation();
if (isRunning) {
alert('Cannot delete a running strategy. Stop it first.');
return;
}
console.log(`Delete button clicked for strategy: ${strat.name}`);
if (this.onDeleteStrategy) {
this.onDeleteStrategy(strat.tbl_key);
} else {
console.error("Delete strategy callback is not set.");
}
});
strategyItem.appendChild(deleteButton);
}
// Run/Stop button // Run/Stop button
const runButton = document.createElement('button'); const runButton = document.createElement('button');
@ -383,11 +441,20 @@ class StratUIManager {
// Strategy icon // Strategy icon
const strategyIcon = document.createElement('div'); const strategyIcon = document.createElement('div');
strategyIcon.className = isRunning ? 'strategy-icon running' : 'strategy-icon'; strategyIcon.className = isRunning ? 'strategy-icon running' : 'strategy-icon';
if (isSubscribed) {
strategyIcon.classList.add('subscribed');
}
strategyIcon.addEventListener('click', () => { strategyIcon.addEventListener('click', () => {
console.log(`Strategy icon clicked for strategy: ${strat.name}`); console.log(`Strategy icon clicked for strategy: ${strat.name}`);
this.displayForm('edit', strat).catch(error => { if (isSubscribed) {
console.error('Error displaying form:', error); // 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 // Strategy name
@ -395,18 +462,31 @@ class StratUIManager {
strategyName.className = 'strategy-name'; strategyName.className = 'strategy-name';
strategyName.textContent = strat.name || 'Unnamed Strategy'; strategyName.textContent = strat.name || 'Unnamed Strategy';
strategyIcon.appendChild(strategyName); 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); strategyItem.appendChild(strategyIcon);
// Strategy hover details with run controls // Strategy hover details with run controls
const strategyHover = document.createElement('div'); const strategyHover = document.createElement('div');
strategyHover.className = 'strategy-hover'; strategyHover.className = 'strategy-hover';
const strategyKey = String(strat.tbl_key || '');
const strategyKeyHtml = escapeHtml(strategyKey);
const strategyKeyJs = escapeHtml(escapeJsString(strategyKey));
// Build hover content // Build hover content (escape user-controlled values)
let hoverHtml = `<strong>${strat.name || 'Unnamed Strategy'}</strong>`; let hoverHtml = `<strong>${escapeHtml(strat.name || 'Unnamed Strategy')}</strong>`;
// Show running status if applicable // Show running status if applicable
if (isRunning) { if (isRunning) {
let modeDisplay = runningInfo.mode; let modeDisplay = runningInfo.mode;
const safeModeDisplay = escapeHtml(modeDisplay);
let modeBadge = ''; let modeBadge = '';
// Add testnet/production badge for live mode // Add testnet/production badge for live mode
@ -420,7 +500,7 @@ class StratUIManager {
let statusHtml = ` let statusHtml = `
<div class="strategy-status running"> <div class="strategy-status running">
Running in <strong>${modeDisplay}</strong> mode ${modeBadge}`; Running in <strong>${safeModeDisplay}</strong> mode ${modeBadge}`;
// Show balance if available // Show balance if available
if (runningInfo.balance !== undefined) { if (runningInfo.balance !== undefined) {
@ -432,7 +512,8 @@ class StratUIManager {
// Show circuit breaker status for live mode // Show circuit breaker status for live mode
if (runningInfo.circuit_breaker && runningInfo.circuit_breaker.tripped) { 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>`; statusHtml += `</div>`;
@ -441,20 +522,20 @@ class StratUIManager {
// Stats // Stats
if (strat.stats && Object.keys(strat.stats).length > 0) { 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 // Run controls
hoverHtml += ` hoverHtml += `
<div class="strategy-controls"> <div class="strategy-controls">
<select id="mode-select-${strat.tbl_key}" ${isRunning ? 'disabled' : ''} <select id="mode-select-${strategyKeyHtml}" ${isRunning ? 'disabled' : ''}
onchange="UI.strats.onModeChange('${strat.tbl_key}', this.value)"> onchange="UI.strats.onModeChange('${strategyKeyJs}', this.value)">
<option value="paper" ${runningInfo?.mode === 'paper' ? 'selected' : ''}>Paper Trading</option> <option value="paper" ${runningInfo?.mode === 'paper' ? 'selected' : ''}>Paper Trading</option>
<option value="live" ${runningInfo?.mode === 'live' ? 'selected' : ''}>Live Trading</option> <option value="live" ${runningInfo?.mode === 'live' ? 'selected' : ''}>Live Trading</option>
</select> </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;"> <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) Testnet Mode (Recommended)
</label> </label>
<small style="color: #ff6600; font-size: 9px; display: block; margin-top: 3px;"> <small style="color: #ff6600; font-size: 9px; display: block; margin-top: 3px;">
@ -463,8 +544,8 @@ class StratUIManager {
</div> </div>
<button class="btn-run ${isRunning ? 'running' : ''}" <button class="btn-run ${isRunning ? 'running' : ''}"
onclick="event.stopPropagation(); ${isRunning onclick="event.stopPropagation(); ${isRunning
? `UI.strats.stopStrategy('${strat.tbl_key}')` ? `UI.strats.stopStrategy('${strategyKeyJs}')`
: `UI.strats.runStrategyWithOptions('${strat.tbl_key}')` : `UI.strats.runStrategyWithOptions('${strategyKeyJs}')`
}"> }">
${isRunning ? 'Stop Strategy' : 'Run Strategy'} ${isRunning ? 'Stop Strategy' : 'Run Strategy'}
</button> </button>
@ -675,6 +756,87 @@ class StratUIManager {
timeframe: timeframeEl ? timeframeEl.value : '5m' 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 { class StratDataManager {
@ -1226,6 +1388,12 @@ class Strategies {
this.comms.on('strategy_status', this.handleStrategyStatus.bind(this)); this.comms.on('strategy_status', this.handleStrategyStatus.bind(this));
this.comms.on('strategy_events', this.handleStrategyEvents.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 // Fetch saved strategies using DataManager
this.dataManager.fetchSavedStrategies(this.comms, this.data); this.dataManager.fetchSavedStrategies(this.comms, this.data);
@ -2022,4 +2190,117 @@ class Strategies {
async generateWithAI() { async generateWithAI() {
await this.uiManager.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"> <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="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> <hr>
<h3>Strategies</h3> <h3>Strategies</h3>
<div class="strategies-container" id="strats_display"></div> <div class="strategies-container" id="strats_display"></div>
@ -199,4 +200,185 @@
100% { box-shadow: 0 0 0 0 rgba(40, 167, 69, 0); } 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> </style>

View File

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