brighter-trading/src/trade.py

1845 lines
79 KiB
Python

import json
import logging
import uuid
import datetime as dt
from typing import Any
from Users import Users
# Configure logging
logger = logging.getLogger(__name__)
# Debug file logger for trade updates
_debug_logger = logging.getLogger('trade_debug')
_debug_logger.setLevel(logging.DEBUG)
_debug_handler = logging.FileHandler('/home/rob/PycharmProjects/BrighterTrading/trade_debug.log', mode='w')
_debug_handler.setFormatter(logging.Formatter('%(asctime)s - %(message)s'))
_debug_logger.addHandler(_debug_handler)
class Trade:
def __init__(self, target: str, symbol: str, side: str, order_price: float, base_order_qty: float,
order_type: str = 'MARKET', time_in_force: str = 'GTC', unique_id: str | None = None,
status: str | None = None, stats: dict[str, Any] | None = None,
order: Any | None = None, fee: float = 0.001, strategy_id: str | None = None,
is_paper: bool = False, creator: int | None = None, created_at: str | None = None,
tbl_key: str | None = None, testnet: bool = False, exchange: str | None = None,
broker_kind: str | None = None, broker_mode: str | None = None,
broker_exchange: str | None = None, broker_order_id: str | None = None,
exchange_order_id: str | None = None,
stop_loss: float | None = None, take_profit: float | None = None):
"""
Initializes a Trade instance with all necessary attributes.
"""
self.unique_id = unique_id or uuid.uuid4().hex
self.tbl_key = tbl_key or self.unique_id
self.target = target
# exchange: for live trades, the actual exchange; for paper trades, use 'paper' (single synthetic market)
if target in ['test_exchange', 'paper', 'Paper Trade']:
self.exchange = 'paper' # Paper trades use a single synthetic market
else:
self.exchange = exchange or target
self.symbol = symbol
self.side = side.upper()
self.order_price = order_price
self.order_type = order_type.upper()
self.time_in_force = time_in_force.upper()
self.base_order_qty = base_order_qty
self.fee = fee
self.strategy_id = strategy_id
self.is_paper = is_paper
self.testnet = testnet
self.creator = creator
self.created_at = created_at or dt.datetime.now(dt.timezone.utc).isoformat()
# Broker integration fields
self.broker_kind = broker_kind # 'paper' or 'live'
self.broker_mode = broker_mode # 'testnet', 'production', or 'paper'
self.broker_exchange = broker_exchange # Exchange name (for live)
self.broker_order_id = broker_order_id # Local broker order ID
self.exchange_order_id = exchange_order_id # Live exchange order ID
# Stop Loss / Take Profit (triggers auto-close when price crosses threshold)
self.stop_loss = stop_loss
self.take_profit = take_profit
if status is None:
self.status = 'inactive'
self.stats = {
'opening_price': order_price,
'opening_value': self.base_order_qty * order_price,
'current_price': order_price,
'current_value': self.base_order_qty * order_price,
'settled_price': 0.0,
'settled_value': 0.0,
'qty_filled': 0.0,
'qty_settled': 0.0,
'profit': 0.0,
'profit_pct': 0.0,
'fee_paid': 0.0,
'realized_profit': 0.0,
'unrealized_profit': 0.0
}
self.order = None
else:
self.status = status
self.stats = stats
self.order = order
def to_json(self) -> dict[str, Any]:
"""
Serializes the Trade object to a JSON-compatible dictionary.
"""
return {
'unique_id': self.unique_id,
'tbl_key': self.tbl_key,
'strategy_id': self.strategy_id,
'target': self.target,
'exchange': self.exchange,
'symbol': self.symbol,
'side': self.side,
'order_price': self.order_price,
'base_order_qty': self.base_order_qty,
'order_type': self.order_type,
'time_in_force': self.time_in_force,
'status': self.status,
'stats': self.stats,
'order': self.order,
'is_paper': self.is_paper,
'testnet': self.testnet,
'creator': self.creator,
'created_at': self.created_at,
'broker_kind': self.broker_kind,
'broker_mode': self.broker_mode,
'broker_exchange': self.broker_exchange,
'broker_order_id': self.broker_order_id,
'exchange_order_id': self.exchange_order_id,
'stop_loss': self.stop_loss,
'take_profit': self.take_profit
}
def get_position_size(self) -> float:
"""
Position_size is the value of the trade in the quote currency.
"""
return self.stats['current_value']
def get_pl(self) -> float:
"""
Returns a positive profit or a negative loss.
"""
return self.stats['profit']
def get_pl_pct(self) -> float:
"""
Returns the profit/loss percentage.
"""
return self.stats['profit_pct']
def get_status(self) -> str:
"""
Returns the current status of the trade.
"""
return self.status
@staticmethod
def _percent(part: float, whole: float) -> float:
if whole == 0:
return 0.0
return 100.0 * float(part) / float(whole)
@staticmethod
def _calculate_pl(entry_price: float, exit_price: float, qty: float, side: str, fee: float) -> float:
entry_value = qty * entry_price
exit_value = qty * exit_price
profit = exit_value - entry_value
if side == 'SELL':
profit *= -1
fees = (entry_value * fee) + (exit_value * fee)
return profit - fees
def _filled_qty(self) -> float:
return float(self.stats.get('qty_filled', 0.0) or 0.0)
def _settled_qty(self) -> float:
return float(self.stats.get('qty_settled', 0.0) or 0.0)
def _open_qty(self) -> float:
return max(self._filled_qty() - self._settled_qty(), 0.0)
def update_values(self, current_price: float) -> None:
"""
Updates the P/L values and percentages based on the current price.
"""
self.stats['current_price'] = current_price
opening_price = float(self.stats.get('opening_price', self.order_price) or self.order_price or 0.0)
filled_qty = self._filled_qty()
open_qty = self._open_qty()
realized_profit = float(self.stats.get('realized_profit', 0.0) or 0.0)
if open_qty > 0:
self.stats['current_value'] = open_qty * current_price
unrealized_profit = self._calculate_pl(
entry_price=opening_price,
exit_price=current_price,
qty=open_qty,
side=self.side,
fee=self.fee
)
else:
# Keep legacy order-notional display for resting orders, but they should not show P/L.
if filled_qty <= 0 and self.status in ['inactive', 'pending', 'open', 'unfilled']:
self.stats['current_value'] = self.base_order_qty * current_price
else:
self.stats['current_value'] = 0.0
unrealized_profit = 0.0
logger.debug(f"Trade {self.unique_id}: Updated current value to {self.stats['current_value']}")
self.stats['unrealized_profit'] = unrealized_profit
self.stats['profit'] = realized_profit + unrealized_profit
basis_qty = filled_qty if filled_qty > 0 else 0.0
basis_value = basis_qty * opening_price
self.stats['profit_pct'] = self._percent(self.stats['profit'], basis_value)
logger.debug(f"Trade {self.unique_id}: Profit updated to {self.stats['profit']} ({self.stats['profit_pct']}%)")
def update(self, current_price: float) -> str:
"""
Updates the trade based on the current price and returns the updated status.
"""
if self.status == 'closed':
return self.status
self.update_values(current_price)
# Determine if the trade has been filled or partially filled
if self.status in ['filled', 'part-filled']:
return self.status
else:
return 'updated' # Indicates that the trade has been updated but not filled
def order_placed(self, order: Any) -> None:
"""
Updates the trade status and stores the order after it has been placed on the exchange.
"""
self.status = 'unfilled'
self.order = order
def trade_filled(self, qty: float, price: float) -> None:
"""
Records the quantity filled, updates trade statistics, and sets the status.
"""
if self.status == 'inactive':
self.status = 'unfilled'
current_filled = self._filled_qty()
if current_filled <= 0:
self.stats['qty_filled'] = qty
self.stats['opening_price'] = price
else:
sum_of_values = (qty * price) + (current_filled * self.stats['opening_price'])
t_qty = current_filled + qty
weighted_average = sum_of_values / t_qty if t_qty != 0 else 0.0
self.stats['opening_price'] = weighted_average
self.stats['qty_filled'] = t_qty
self.stats['opening_value'] = self.stats['qty_filled'] * self.stats['opening_price']
if self.stats['qty_filled'] >= self.base_order_qty:
self.status = 'filled'
else:
self.status = 'part-filled'
current_price = float(self.stats.get('current_price', 0.0) or 0.0)
if current_price <= 0:
current_price = price
self.update_values(current_price)
def settle(self, qty: float, price: float) -> None:
"""
Settles all or part of the trade based on the provided quantity and price.
"""
qty = float(qty or 0.0)
if qty <= 0:
return
filled_qty = self._filled_qty()
open_qty = self._open_qty()
if filled_qty > 0 and open_qty > 0:
qty = min(qty, open_qty)
if qty <= 0:
return
if self.stats['qty_settled'] == 0:
self.stats['settled_price'] = price
self.stats['settled_value'] = qty * price
self.stats['qty_settled'] = qty
else:
sum_of_values = (qty * price) + self.stats['settled_value']
t_qty = self.stats['qty_settled'] + qty
weighted_average = sum_of_values / t_qty if t_qty != 0 else 0.0
self.stats['settled_price'] = weighted_average
self.stats['qty_settled'] += qty
self.stats['settled_value'] = self.stats['qty_settled'] * self.stats['settled_price']
realized_increment = self._calculate_pl(
entry_price=float(self.stats.get('opening_price', self.order_price) or self.order_price or 0.0),
exit_price=price,
qty=qty,
side=self.side,
fee=self.fee
)
self.stats['realized_profit'] = float(self.stats.get('realized_profit', 0.0) or 0.0) + realized_increment
if self._open_qty() <= 0:
self.stats['current_price'] = price
self.stats['current_value'] = 0.0
self.stats['unrealized_profit'] = 0.0
self.stats['profit'] = self.stats['realized_profit']
basis_qty = self._filled_qty() if self._filled_qty() > 0 else self.base_order_qty
basis_value = basis_qty * float(self.stats.get('opening_price', self.order_price) or self.order_price or 0.0)
self.stats['profit_pct'] = self._percent(self.stats['profit'], basis_value)
else:
self.update_values(float(self.stats.get('current_price', price) or price))
close_qty = filled_qty if filled_qty > 0 else self.base_order_qty
if self.stats['qty_settled'] >= close_qty:
self.status = 'closed'
class Trades:
def __init__(self, users: Users, data_cache: Any = None):
"""
Initializes the Trades class with necessary attributes.
:param users: Users instance for user lookups.
:param data_cache: DataCache instance for persistence.
"""
self.users = users
self.data_cache = data_cache
self.exchange_interface: Any | None = None # Define the type based on your exchange interface
self.manual_broker_manager: Any | None = None # ManualTradingBrokerManager for broker-based trading
self.exchange_fees = {'maker': 0.001, 'taker': 0.001}
self.hedge_mode = False
self.side: str | None = None
self.active_trades: dict[str, Trade] = {} # Keyed by trade.unique_id
self.settled_trades: dict[str, Trade] = {}
self.stats = {'num_trades': 0, 'total_position': 0, 'total_position_value': 0}
self.balances: dict[str, float] = {} # Track balances per strategy
self.locked_funds: dict[str, float] = {} # Track locked funds per strategy
# Initialize database persistence if data_cache is available
if self.data_cache:
self._ensure_table_exists()
self._create_cache()
self._load_trades_from_db()
def _ensure_table_exists(self) -> None:
"""Create the trades table in the database if it doesn't exist."""
try:
if not self.data_cache.db.table_exists('trades'):
create_sql = """
CREATE TABLE IF NOT EXISTS trades (
id INTEGER PRIMARY KEY AUTOINCREMENT,
creator INTEGER,
unique_id TEXT UNIQUE,
target TEXT NOT NULL,
symbol TEXT NOT NULL,
side TEXT NOT NULL,
order_type TEXT NOT NULL,
order_price REAL,
base_order_qty REAL NOT NULL,
time_in_force TEXT DEFAULT 'GTC',
fee REAL DEFAULT 0.001,
status TEXT DEFAULT 'inactive',
stats_json TEXT,
strategy_id TEXT,
is_paper INTEGER DEFAULT 0,
testnet INTEGER DEFAULT 0,
created_at TEXT,
tbl_key TEXT UNIQUE,
broker_kind TEXT,
broker_mode TEXT,
broker_exchange TEXT,
broker_order_id TEXT,
exchange_order_id TEXT,
stop_loss REAL,
take_profit REAL
)
"""
self.data_cache.db.execute_sql(create_sql, params=[])
logger.info("Created trades table in database")
else:
# Ensure testnet column exists for existing databases
self._ensure_testnet_column()
# Ensure broker columns exist for existing databases
self._ensure_broker_columns()
except Exception as e:
logger.error(f"Error ensuring trades table exists: {e}", exc_info=True)
def _ensure_testnet_column(self) -> None:
"""Add testnet column to trades table if it doesn't exist."""
try:
# Check if testnet column exists
result = self.data_cache.db.execute_sql(
"PRAGMA table_info(trades)", params=[]
)
columns = {row[1] for row in result} if result else set()
if 'testnet' not in columns:
self.data_cache.db.execute_sql(
"ALTER TABLE trades ADD COLUMN testnet INTEGER DEFAULT 0", params=[]
)
logger.info("Added testnet column to trades table")
except Exception as e:
logger.debug(f"Could not add testnet column: {e}")
def _ensure_broker_columns(self) -> None:
"""Add broker tracking columns to trades table if they don't exist."""
broker_columns = [
('broker_kind', 'TEXT'),
('broker_mode', 'TEXT'),
('broker_exchange', 'TEXT'),
('broker_order_id', 'TEXT'),
('exchange_order_id', 'TEXT'),
('stop_loss', 'REAL'),
('take_profit', 'REAL'),
]
try:
result = self.data_cache.db.execute_sql(
"PRAGMA table_info(trades)", params=[]
)
existing_columns = {row[1] for row in result} if result else set()
for col_name, col_type in broker_columns:
if col_name not in existing_columns:
try:
self.data_cache.db.execute_sql(
f"ALTER TABLE trades ADD COLUMN {col_name} {col_type}", params=[]
)
logger.info(f"Added {col_name} column to trades table")
except Exception as e:
logger.debug(f"Could not add {col_name} column: {e}")
except Exception as e:
logger.debug(f"Could not ensure broker columns: {e}")
def _create_cache(self) -> None:
"""Create the trades cache in DataCache."""
try:
self.data_cache.create_cache(
name='trades',
cache_type='table',
size_limit=1000,
eviction_policy='deny',
default_expiration=dt.timedelta(hours=24),
columns=[
"creator",
"unique_id",
"target",
"symbol",
"side",
"order_type",
"order_price",
"base_order_qty",
"time_in_force",
"fee",
"status",
"stats_json",
"strategy_id",
"is_paper",
"testnet",
"created_at",
"tbl_key",
"broker_kind",
"broker_mode",
"broker_exchange",
"broker_order_id",
"exchange_order_id",
"stop_loss",
"take_profit"
]
)
except Exception as e:
logger.debug(f"Cache 'trades' may already exist: {e}")
def _load_trades_from_db(self) -> None:
"""Load all trades from database into memory (active + settled)."""
try:
trades_df = self.data_cache.get_all_rows_from_datacache(cache_name='trades')
if trades_df is not None and not trades_df.empty:
active_count = 0
settled_count = 0
for _, row in trades_df.iterrows():
status = row.get('status', 'inactive')
# Parse stats JSON
stats_json = row.get('stats_json', '{}')
try:
stats = json.loads(stats_json) if stats_json else {}
except (json.JSONDecodeError, TypeError):
stats = {}
trade = Trade(
target=row.get('target', ''),
symbol=row.get('symbol', ''),
side=row.get('side', 'BUY'),
order_price=float(row.get('order_price', 0)),
base_order_qty=float(row.get('base_order_qty', 0)),
order_type=row.get('order_type', 'MARKET'),
time_in_force=row.get('time_in_force', 'GTC'),
unique_id=row.get('unique_id'),
status=status,
stats=stats if stats else None,
fee=float(row.get('fee', 0.001)),
strategy_id=row.get('strategy_id'),
is_paper=bool(row.get('is_paper', 0)),
testnet=bool(row.get('testnet', 0)),
creator=row.get('creator'),
created_at=row.get('created_at'),
tbl_key=row.get('tbl_key'),
broker_kind=row.get('broker_kind'),
broker_mode=row.get('broker_mode'),
broker_exchange=row.get('broker_exchange'),
broker_order_id=row.get('broker_order_id'),
exchange_order_id=row.get('exchange_order_id'),
stop_loss=row.get('stop_loss'),
take_profit=row.get('take_profit')
)
# Route to appropriate collection based on status
if status in ['closed', 'cancelled']:
self.settled_trades[trade.unique_id] = trade
settled_count += 1
else:
self.active_trades[trade.unique_id] = trade
self.stats['num_trades'] += 1
active_count += 1
logger.info(f"Loaded {active_count} active trades and {settled_count} settled trades from database")
except Exception as e:
logger.error(f"Error loading trades from database: {e}", exc_info=True)
def _save_trade(self, trade: Trade) -> bool:
"""
Save a trade to the database.
:param trade: Trade object to save.
:return: True if successful, False otherwise.
"""
if not self.data_cache:
return True # No persistence, just return success
try:
columns = (
"creator", "unique_id", "target", "symbol", "side", "order_type",
"order_price", "base_order_qty", "time_in_force", "fee", "status",
"stats_json", "strategy_id", "is_paper", "testnet", "created_at", "tbl_key",
"broker_kind", "broker_mode", "broker_exchange", "broker_order_id", "exchange_order_id",
"stop_loss", "take_profit"
)
stats_json = json.dumps(trade.stats) if trade.stats else '{}'
values = (
trade.creator,
trade.unique_id,
trade.target,
trade.symbol,
trade.side,
trade.order_type,
trade.order_price,
trade.base_order_qty,
trade.time_in_force,
trade.fee,
trade.status,
stats_json,
trade.strategy_id,
int(trade.is_paper),
int(trade.testnet),
trade.created_at,
trade.tbl_key,
trade.broker_kind,
trade.broker_mode,
trade.broker_exchange,
trade.broker_order_id,
trade.exchange_order_id,
trade.stop_loss,
trade.take_profit
)
# Check if trade already exists
existing = self.data_cache.get_rows_from_datacache(
cache_name='trades',
filter_vals=[('tbl_key', trade.tbl_key)],
include_tbl_key=True
)
if existing.empty:
# Insert new trade
self.data_cache.insert_row_into_datacache(
cache_name='trades',
columns=columns,
values=values
)
else:
# Update existing trade
self.data_cache.modify_datacache_item(
cache_name='trades',
filter_vals=[('tbl_key', trade.tbl_key)],
field_names=columns,
new_values=values,
key=trade.tbl_key,
overwrite='tbl_key'
)
return True
except Exception as e:
logger.error(f"Failed to save trade {trade.unique_id}: {e}", exc_info=True)
return False
def _update_trade_in_db(self, trade: Trade) -> bool:
"""
Update a trade's stats in the database.
:param trade: Trade object to update.
:return: True if successful, False otherwise.
"""
return self._save_trade(trade)
def _delete_trade_from_db(self, trade_id: str) -> bool:
"""
Delete a trade from the database.
:param trade_id: The unique ID of the trade to delete.
:return: True if successful, False otherwise.
"""
if not self.data_cache:
return True
try:
self.data_cache.remove_row_from_datacache(
cache_name='trades',
filter_vals=[('unique_id', trade_id)]
)
return True
except Exception as e:
logger.error(f"Failed to delete trade {trade_id}: {e}", exc_info=True)
return False
def new_trade(self, target: str, symbol: str, price: float, side: str,
order_type: str, qty: float, user_id: int = None,
strategy_id: str = None, testnet: bool = False, exchange: str = None,
stop_loss: float = None, take_profit: float = None,
time_in_force: str = 'GTC') -> tuple[str, str | None]:
"""
Creates a new trade (paper or live).
:param target: The exchange target ('test_exchange' for paper, exchange name for live).
:param symbol: The trading pair symbol.
:param price: The price to trade at (ignored for market orders).
:param side: 'BUY' or 'SELL'.
:param order_type: 'MARKET' or 'LIMIT'.
:param qty: The quantity to trade.
:param user_id: The user creating the trade.
:param strategy_id: Optional strategy ID if from a strategy.
:param testnet: Whether to use testnet/sandbox mode for live trades.
:param exchange: The actual exchange for price data (for paper trades).
:param stop_loss: Optional stop loss price.
:param take_profit: Optional take profit price.
:param time_in_force: Order time-in-force ('GTC', 'IOC', 'FOK').
:return: Tuple of (status, trade_id or error message).
"""
from brokers.base_broker import OrderSide, OrderType, OrderStatus
# Determine if this is a paper trade
is_paper = target in ['test_exchange', 'paper', 'Paper Trade']
time_in_force = (time_in_force or 'GTC').upper()
# === PRODUCTION SAFETY GATE (BEFORE any broker/exchange creation) ===
if not is_paper and not testnet:
import config
if not getattr(config, 'ALLOW_LIVE_PRODUCTION', False):
logger.warning(
f"Production trading blocked: ALLOW_LIVE_PRODUCTION not set. "
f"User {user_id} attempted production trade on {target}."
)
return 'Error', 'Production trading is disabled. Set BRIGHTER_ALLOW_LIVE_PROD=true to enable.'
# For live trades, validate exchange is configured BEFORE creating trade
if not is_paper:
if not self.exchange_connected():
return 'Error', 'No exchange interface connected. Cannot place live trades.'
# Check if user has this exchange configured
user_name = self._get_user_name(user_id) if user_id else None
if not user_name:
return 'Error', 'You must be logged in to place live trades.'
try:
exchange_obj = self.exchange_interface.get_exchange(ename=target, uname=user_name)
if not exchange_obj or not exchange_obj.configured:
return 'Error', f'Exchange "{target}" is not configured with API keys. Please configure it in the Exchanges panel first.'
except ValueError:
return 'Error', f'Exchange "{target}" is not connected. Please add it in the Exchanges panel first.'
if not is_paper and (stop_loss is not None or take_profit is not None):
return 'Error', 'Manual live Stop Loss / Take Profit is not supported yet. Use paper trading for SL/TP for now.'
# For market orders, fetch the current price from exchange
# For paper trades, use the specified exchange for consistent pricing
effective_price = float(price) if price else 0.0
if order_type and order_type.upper() == 'MARKET' and self.exchange_interface:
try:
# Use exchange-aware price lookup for paper trades
price_exchange = exchange if is_paper and exchange else (target if not is_paper else None)
current_price = self.exchange_interface.get_price(symbol, price_exchange)
if current_price:
effective_price = float(current_price)
logger.debug(f"Market order: using current price {effective_price} for {symbol} from {price_exchange or 'default'}")
except Exception as e:
logger.warning(f"Could not fetch current price for {symbol}: {e}, using provided price {price}")
# Fetch trading fees from exchange (falls back to defaults if unavailable)
effective_fee = self.exchange_fees.get('taker', 0.001) # Default to taker fee
if self.exchange_interface:
try:
user_name = self._get_user_name(user_id) if user_id else None
fee_info = self.exchange_interface.get_trading_fees(
symbol=symbol,
user_name=user_name,
exchange_name=target if not is_paper else None
)
# Use taker fee for market orders, maker fee for limit orders
if order_type and order_type.upper() == 'LIMIT':
effective_fee = fee_info.get('maker', 0.001)
else:
effective_fee = fee_info.get('taker', 0.001)
logger.debug(f"Trade fee for {symbol}: {effective_fee} (source: {fee_info.get('source', 'unknown')})")
except Exception as e:
logger.warning(f"Could not fetch trading fees for {symbol}: {e}, using default {effective_fee}")
try:
# === BROKER-BASED ORDER PLACEMENT ===
if self.manual_broker_manager and user_id:
# Get appropriate broker FIRST (need it for SELL validation)
if is_paper:
broker = self.manual_broker_manager.get_paper_broker(user_id)
broker_kind = 'paper'
broker_mode = 'paper'
broker_exchange = None
broker_key = 'paper'
else:
user_name = self._get_user_name(user_id)
broker = self.manual_broker_manager.get_live_broker(
user_id, target, testnet, user_name
)
if not broker:
return 'Error', f'Could not create broker for exchange "{target}".'
broker_kind = 'live'
broker_mode = 'testnet' if testnet else 'production'
broker_exchange = target
broker_key = f"{target}_{broker_mode}"
# Inventory-only SELL (applies to BOTH paper and live)
if side.upper() == 'SELL':
position = broker.get_position(symbol)
if not position or position.size <= 0:
return 'Error', 'Cannot sell: no position in this symbol. Buy first.'
# Place order through broker
order_side = OrderSide.BUY if side.upper() == 'BUY' else OrderSide.SELL
order_type_enum = OrderType.MARKET if order_type.upper() == 'MARKET' else OrderType.LIMIT
# Build order kwargs - paper trades get exchange for price source tracking
order_kwargs = {
'symbol': symbol,
'side': order_side,
'order_type': order_type_enum,
'size': float(qty),
'price': effective_price if order_type.upper() == 'LIMIT' else None,
'stop_loss': stop_loss,
'take_profit': take_profit,
'time_in_force': time_in_force,
}
if is_paper and exchange:
# Paper trades track exchange for price source
order_kwargs['exchange'] = exchange
result = broker.place_order(**order_kwargs)
if not result.success:
return 'Error', result.message or 'Order placement failed'
# Map OrderStatus to trade status string
status_map = {
OrderStatus.PENDING: 'pending',
OrderStatus.OPEN: 'open',
OrderStatus.FILLED: 'filled',
OrderStatus.PARTIALLY_FILLED: 'part-filled',
OrderStatus.CANCELLED: 'cancelled',
OrderStatus.REJECTED: 'rejected',
OrderStatus.EXPIRED: 'expired',
}
trade_status = status_map.get(result.status, 'pending')
# Create Trade with full broker tracking
# Note: Trade.__init__ normalizes exchange to 'paper' for paper trades
trade = Trade(
target=target,
exchange=exchange,
symbol=symbol,
side=side.upper(),
order_price=effective_price,
base_order_qty=float(qty),
order_type=order_type.upper() if order_type else 'MARKET',
time_in_force=time_in_force,
strategy_id=strategy_id,
is_paper=is_paper,
testnet=testnet,
creator=user_id,
fee=effective_fee,
broker_kind=broker_kind,
broker_mode=broker_mode,
broker_exchange=broker_exchange,
broker_order_id=result.order_id,
exchange_order_id=result.exchange_order_id,
stop_loss=stop_loss,
take_profit=take_profit
)
trade.status = trade_status
# Update stats if order was filled immediately (market orders)
if result.status == OrderStatus.FILLED:
trade.stats['qty_filled'] = result.filled_qty or float(qty)
# Validate filled_price - detect unreasonable deviations from effective_price
filled_price = result.filled_price or effective_price
if effective_price > 0 and filled_price > 0:
price_ratio = filled_price / effective_price
# Price shouldn't deviate by more than 10% from market price for market orders
if price_ratio > 1.1 or price_ratio < 0.9:
logger.warning(
f"[PRICE VALIDATION] Suspicious filled_price detected! "
f"filled_price={filled_price}, effective_price={effective_price}, "
f"ratio={price_ratio:.2f}. Using effective_price instead."
)
filled_price = effective_price
trade.stats['opening_price'] = filled_price
trade.stats['opening_value'] = trade.stats['qty_filled'] * trade.stats['opening_price']
trade.stats['current_value'] = trade.stats['opening_value']
logger.debug(
f"[FILL STATS] trade={trade.unique_id[:8]}, qty_filled={trade.stats['qty_filled']}, "
f"opening_price={trade.stats['opening_price']}, result.filled_price={result.filled_price}"
)
logger.info(f"Broker trade created: {trade.unique_id} {side} {qty} {symbol} @ {effective_price} "
f"(broker_kind={broker_kind}, status={trade_status})")
else:
# === LEGACY PATH (no broker manager) ===
trade = Trade(
target=target,
exchange=exchange,
symbol=symbol,
side=side.upper(),
order_price=effective_price,
base_order_qty=float(qty),
order_type=order_type.upper() if order_type else 'MARKET',
time_in_force=time_in_force,
strategy_id=strategy_id,
is_paper=is_paper,
testnet=testnet,
creator=user_id,
fee=effective_fee,
stop_loss=stop_loss,
take_profit=take_profit
)
if is_paper:
# Paper trade: simulate immediate fill
trade.status = 'filled'
trade.stats['qty_filled'] = trade.base_order_qty
trade.stats['opening_price'] = trade.order_price
trade.stats['opening_value'] = trade.base_order_qty * trade.order_price
trade.stats['current_value'] = trade.stats['opening_value']
logger.info(f"Paper trade created: {trade.unique_id} {side} {qty} {symbol} @ {effective_price}")
else:
# Live trade: place order on exchange (legacy path)
mode_str = "testnet" if testnet else "production"
logger.info(f"Live trade ({mode_str}): {trade.unique_id} {side} {qty} {symbol} @ {effective_price}")
if not self.exchange_connected():
return 'Error', 'No exchange connected'
user_name = self._get_user_name(user_id) if user_id else 'unknown'
status, msg = self.place_order(trade, user_name=user_name)
if status != 'success':
return 'Error', msg
# Add to active trades
self.active_trades[trade.unique_id] = trade
self.stats['num_trades'] += 1
# Persist to database
self._save_trade(trade)
return 'Success', trade.unique_id
except Exception as e:
logger.error(f"Error creating new trade: {e}", exc_info=True)
return 'Error', str(e)
def settle_broker_closed_position(self, user_id: int, symbol: str, broker_key: str,
close_price: float) -> list[str]:
"""
Reconcile local trades after a broker-side position close.
This is used when the broker closes the position outside the normal
close_position() API flow, such as paper SL/TP triggers.
"""
settled_ids = []
for trade_id, trade in list(self.active_trades.items()):
if broker_key == 'paper':
matches_broker = trade.broker_kind == 'paper'
else:
trade_broker_key = f"{trade.broker_exchange}_{trade.broker_mode}"
matches_broker = (trade.broker_kind == 'live' and trade_broker_key == broker_key)
if not (trade.creator == user_id and trade.symbol == symbol and matches_broker):
continue
if trade.status not in ['filled', 'part-filled']:
continue
qty_to_settle = trade.stats.get('qty_filled', trade.base_order_qty)
if qty_to_settle <= 0:
continue
trade.settle(qty=qty_to_settle, price=close_price)
trade.status = 'closed'
self._save_trade(trade)
del self.active_trades[trade_id]
self.settled_trades[trade_id] = trade
self.stats['num_trades'] -= 1
settled_ids.append(trade.unique_id)
return settled_ids
def get_trades_for_user(self, user_id: int, form: str = 'json') -> list:
"""
Returns trades visible to a specific user.
:param user_id: The user ID to filter trades for.
:param form: Output format ('json', 'obj', 'dict').
:return: List of trades.
"""
user_trades = [
trade for trade in self.active_trades.values()
if trade.creator == user_id or trade.creator is None
]
if form == 'obj':
return user_trades
elif form == 'json':
return [trade.to_json() for trade in user_trades]
elif form == 'dict':
return [trade.__dict__ for trade in user_trades]
else:
return [trade.to_json() for trade in user_trades]
def get_trade_history(self, user_id: int, limit: int = 50) -> list[dict]:
"""
Get settled/cancelled trade history for a user.
Only includes trades that are truly finished (status='closed' or 'cancelled').
Active positions (status='filled') belong in the Positions panel, not history.
:param user_id: The user ID.
:param limit: Maximum number of trades to return.
:return: List of trade dicts, most recent first.
"""
history = []
# From in-memory settled trades (these are truly closed)
for trade_id, trade in self.settled_trades.items():
if trade.creator == user_id:
history.append(trade.to_json())
# Also check active trades for 'cancelled' status only
# Note: 'filled' = open position (not history), 'closed' should be in settled_trades
for trade_id, trade in self.active_trades.items():
if trade.creator == user_id and trade.status == 'cancelled':
history.append(trade.to_json())
# Sort by timestamp descending (most recent first)
history.sort(key=lambda t: t.get('created_at', 0), reverse=True)
return history[:limit]
def buy(self, order_data: dict[str, Any], user_id: int) -> tuple[str, str | None]:
"""
Executes a buy order.
"""
return self._execute_order(order_data, user_id, side='BUY')
def sell(self, order_data: dict[str, Any], user_id: int) -> tuple[str, str | None]:
"""
Executes a sell order.
"""
return self._execute_order(order_data, user_id, side='SELL')
def _execute_order(self, order_data: dict[str, Any], user_id: int, side: str) -> tuple[str, str | None]:
"""
Internal method to handle order execution.
"""
try:
# Create a new trade
trade = Trade(
target=order_data.get('target', 'exchange_interface'),
symbol=order_data['symbol'],
side=side,
order_price=order_data.get('price', 0.0),
base_order_qty=order_data['size'],
order_type=order_data.get('order_type', 'MARKET'),
time_in_force=order_data.get('tif', 'GTC'),
strategy_id=order_data.get('strategy_id')
)
# Add the trade to active trades
self.active_trades[trade.unique_id] = trade
self.stats['num_trades'] += 1
# Place the order
status, msg = self.place_order(trade, user_name=self._get_user_name(user_id))
if status == 'success':
logger.info(f"Order placed successfully: Trade ID {trade.unique_id}")
return 'success', None
else:
# Remove the trade from active_trades if placement failed
del self.active_trades[trade.unique_id]
self.stats['num_trades'] -= 1
logger.error(f"Order placement failed for Trade ID {trade.unique_id}: {msg}")
return 'fail', msg
except Exception as e:
logger.error(f"Exception during {side} order execution: {e}", exc_info=True)
return 'fail', str(e)
def _get_user_name(self, user_id: int) -> str:
"""
Retrieves the username based on user_id.
"""
try:
return self.users.get_username(user_id)
except Exception as e:
logger.error(f"Failed to retrieve username for user ID {user_id}: {e}", exc_info=True)
return "unknown_user"
def exit_strategy_all(self, strategy_id: str) -> tuple[str, str | None]:
"""
Exits all trades associated with a specific strategy.
"""
try:
trades_to_exit = [trade for trade in self.active_trades.values() if trade.strategy_id == strategy_id]
for trade in trades_to_exit:
self.close_trade(trade.unique_id)
logger.info(f"All trades for strategy {strategy_id} have been exited.")
return 'success', None
except Exception as e:
logger.error(f"Error exiting all trades for strategy '{strategy_id}': {e}", exc_info=True)
return 'fail', str(e)
def exit_strategy_in_profit(self, strategy_id: str) -> tuple[str, str | None]:
"""
Exits trades that are in profit for a specific strategy.
"""
try:
trades_to_exit = [trade for trade in self.active_trades.values()
if trade.strategy_id == strategy_id and trade.stats.get('profit', 0.0) > 0]
for trade in trades_to_exit:
self.close_trade(trade.unique_id)
logger.info(f"Profitable trades for strategy {strategy_id} have been exited.")
return 'success', None
except Exception as e:
logger.error(f"Error exiting profitable trades for strategy '{strategy_id}': {e}", exc_info=True)
return 'fail', str(e)
def exit_strategy_in_loss(self, strategy_id: str) -> tuple[str, str | None]:
"""
Exits trades that are in loss for a specific strategy.
"""
try:
trades_to_exit = [trade for trade in self.active_trades.values()
if trade.strategy_id == strategy_id and trade.stats.get('profit', 0.0) < 0]
for trade in trades_to_exit:
self.close_trade(trade.unique_id)
logger.info(f"Losing trades for strategy {strategy_id} have been exited.")
return 'success', None
except Exception as e:
logger.error(f"Error exiting losing trades for strategy '{strategy_id}': {e}", exc_info=True)
return 'fail', str(e)
def all_trades_closed(self, strategy_id: str) -> bool:
"""
Checks if all trades for the given strategy are closed.
:param strategy_id: Identifier of the strategy.
:return: True if all trades are closed, False otherwise.
"""
try:
trades = self.active_trades.get(strategy_id, [])
return len(trades) == 0
except Exception as e:
logger.error(f"Error checking trades for strategy '{strategy_id}': {e}", exc_info=True)
return False
def notify_user(self, user_id: int, message: str) -> tuple[str, str | None]:
"""
Sends a notification to the specified user.
"""
try:
self.users.notify_user(user_id, message)
return 'success', None
except Exception as e:
logger.error(f"Error notifying user '{user_id}': {e}", exc_info=True)
return 'fail', str(e)
def get_profit(self, strategy_id: str) -> float:
"""
Calculates the total profit for a specific strategy.
"""
total_profit = sum(trade.stats.get('profit', 0.0) for trade in self.active_trades.values()
if trade.strategy_id == strategy_id)
logger.info(f"Total profit for strategy {strategy_id}: {total_profit}")
return total_profit
def get_current_balance(self, user_id: int) -> float:
"""
Retrieves the current balance for a user.
"""
try:
# Implement logic to fetch the user's current balance.
# This could involve querying the exchange interface or a database.
balance = self.exchange_interface.get_user_balance(user_id)
logger.info(f"Current balance for user {user_id}: {balance}")
return balance
except Exception as e:
logger.error(f"Error fetching current balance for user '{user_id}': {e}", exc_info=True)
return 0.0
def connect_exchanges(self, exchanges: Any) -> None:
"""
Connects an exchange interface.
"""
self.exchange_interface = exchanges
logger.info("Exchange interface connected.")
def exchange_connected(self) -> bool:
"""
Reports if an exchange interface has been connected.
"""
return self.exchange_interface is not None
def get_trades(self, form: str) -> Any | None:
"""
Returns stored trades in various formats.
"""
if form == 'obj':
return list(self.active_trades.values())
elif form == 'json':
return [trade.to_json() for trade in self.active_trades.values()]
elif form == 'dict':
return [trade.__dict__ for trade in self.active_trades.values()]
else:
logger.error(f"Invalid form '{form}' requested for get_trades.")
return None
def get_trades_by_status(self, status: str) -> list[Trade]:
"""
Returns a list of active trades with the specified status.
"""
return [trade for trade in self.active_trades.values() if trade.status == status]
def is_valid_trade_id(self, trade_id: str) -> bool:
"""
Validates if a trade ID exists among active trades.
"""
return trade_id in self.active_trades
def get_trade_by_id(self, trade_id: str) -> Trade | None:
"""
Retrieves a trade by its unique ID.
"""
return self.active_trades.get(trade_id, None)
def load_trades(self, trades: list[dict[str, Any]]) -> None:
"""
Loads trades from a list of trade dictionaries.
"""
for trade_data in trades:
trade = Trade(**trade_data)
self.active_trades[trade.unique_id] = trade
self.stats['num_trades'] += 1
logger.info(f"Loaded {len(trades)} trades.")
def get_filled_orders_count(self, strategy_id: str) -> int:
"""
Returns the number of filled orders for the given strategy.
"""
try:
filled_orders = [order for order in self.orders if
order['strategy_id'] == strategy_id and order['status'] == 'filled']
return len(filled_orders)
except Exception as e:
logger.error(f"Error retrieving filled orders for strategy '{strategy_id}': {e}", exc_info=True)
return 0
def get_available_balance(self, strategy_id: str) -> float:
"""
Returns the available balance for the given strategy.
"""
try:
# Implement logic to calculate available balance
# This is a placeholder and should be replaced with actual implementation
return self.balances.get(strategy_id, 0.0) - self.locked_funds.get(strategy_id, 0.0)
except Exception as e:
logger.error(f"Error retrieving available balance for strategy '{strategy_id}': {e}", exc_info=True)
return 0.0
def get_total_filled_order_volume(self, strategy_id: str) -> float:
"""
Returns the total volume of filled orders for the given strategy.
"""
try:
filled_orders = [order for order in self.orders if
order['strategy_id'] == strategy_id and order['status'] == 'filled']
total_volume = sum(order['size'] for order in filled_orders)
return total_volume
except Exception as e:
logger.error(f"Error retrieving filled order volume for strategy '{strategy_id}': {e}", exc_info=True)
return 0.0
def get_total_unfilled_order_volume(self, strategy_id: str) -> float:
"""
Returns the total volume of unfilled orders for the given strategy.
"""
try:
unfilled_orders = [order for order in self.orders if
order['strategy_id'] == strategy_id and order['status'] in ['pending', 'open']]
total_volume = sum(order['size'] for order in unfilled_orders)
return total_volume
except Exception as e:
logger.error(f"Error retrieving unfilled order volume for strategy '{strategy_id}': {e}", exc_info=True)
return 0.0
def get_unfilled_orders_count(self, strategy_id: str) -> int:
"""
Returns the number of unfilled orders for the given strategy.
"""
try:
unfilled_orders = [order for order in self.orders if
order['strategy_id'] == strategy_id and order['status'] in ['pending', 'open']]
return len(unfilled_orders)
except Exception as e:
logger.error(f"Error retrieving unfilled orders for strategy '{strategy_id}': {e}", exc_info=True)
return 0
def place_order(self, trade: Trade, user_name: str) -> tuple[str, str | None]:
"""
Executes a place order command on the exchange interface.
"""
if not self.exchange_connected():
error_msg = 'No exchange_interface connected.'
logger.error(f"Trades:place_order(): {error_msg}")
return 'fail', error_msg
exchange = self.exchange_interface.get_exchange(ename=trade.target, uname=user_name)
try:
if trade.order_type in ['MARKET', 'LIMIT']:
order = exchange.place_order(
symbol=trade.symbol,
side=trade.side,
type=trade.order_type,
timeInForce=trade.time_in_force,
quantity=trade.base_order_qty,
price=trade.order_price if trade.order_type == 'LIMIT' else None
)
elif trade.order_type == 'CHASE':
error_msg = 'Trades:place_order(): CHASE orders not implemented yet.'
logger.error(error_msg)
return 'fail', error_msg
else:
error_msg = f"Trades:place_order(): No implementation for order type: {trade.order_type}"
logger.error(error_msg)
return 'fail', error_msg
except Exception as e:
error_msg = f"Trades:place_order(): {e}"
logger.error(error_msg, exc_info=True)
return 'fail', error_msg
# Assign the exchange's order ID to the trade
if hasattr(order, 'orderId'):
trade.unique_id = order.orderId
else:
logger.warning(f"Order object does not have 'orderId'. Using existing unique_id: {trade.unique_id}")
# Update the trade status and store the order
trade.order_placed(order)
logger.info(f"Trade {trade.unique_id} placed: {trade.side} {trade.symbol} at {trade.order_price}")
return 'success', None
def update(self, price_updates: dict[str, float]) -> list[dict[str, Any]]:
"""
Updates the price for all active trades based on provided price updates.
:param price_updates: Dictionary mapping (exchange:symbol) or symbol to prices.
Keys can be "binance:BTC/USDT" for exchange-specific prices,
or just "BTC/USDT" for generic prices.
:return: List of dictionaries containing updated trade data.
"""
_debug_logger.debug(f"=== Trades.update() called ===")
_debug_logger.debug(f"price_updates: {price_updates}")
_debug_logger.debug(f"active_trades count: {len(self.active_trades)}")
_debug_logger.debug(f"active_trades keys: {list(self.active_trades.keys())}")
r_update = []
for trade_id, trade in list(self.active_trades.items()):
symbol = trade.symbol
exchange = getattr(trade, 'exchange', None) or trade.target
_debug_logger.debug(f"Processing trade_id={trade_id}, symbol={symbol}, exchange={exchange}, status={trade.status}")
# First try exchange-specific price key
exchange_key = f"{exchange.lower()}:{symbol}" if exchange else None
current_price = price_updates.get(exchange_key) if exchange_key else None
_debug_logger.debug(f"Tried exchange_key '{exchange_key}': {current_price}")
# Fall back to symbol-only lookup
if current_price is None:
current_price = price_updates.get(symbol)
_debug_logger.debug(f"Tried symbol '{symbol}': {current_price}")
if current_price is None:
# Try to find a matching symbol (handle format differences like BTC/USD vs BTC/USDT)
for price_key, price in price_updates.items():
# Extract symbol part if key is exchange:symbol format
price_symbol = price_key.split(':')[-1] if ':' in price_key else price_key
# Normalize both symbols for comparison
norm_trade = symbol.upper().replace('/', '')
norm_price = price_symbol.upper().replace('/', '')
if norm_trade == norm_price or norm_trade.rstrip('T') == norm_price.rstrip('T'):
current_price = price
logger.debug(f"Matched trade symbol '{symbol}' to price key '{price_key}'")
break
if current_price is None:
_debug_logger.debug(f"current_price is None after matching, skipping trade {trade_id}")
logger.warning(f"No price update for symbol '{symbol}' (exchange: {exchange}). Available: {list(price_updates.keys())}. Skipping trade {trade_id}.")
continue
_debug_logger.debug(f"current_price resolved to: {current_price}")
_debug_logger.debug(f"Checking trade.status: '{trade.status}' in ['unfilled', 'part-filled']")
if trade.status in ['unfilled', 'part-filled']:
status = self.exchange_interface.get_trade_status(trade)
if status in ['FILLED', 'PARTIALLY_FILLED']:
executed_qty = self.exchange_interface.get_trade_executed_qty(trade)
try:
executed_price = self.exchange_interface.get_trade_executed_price(
trade, fallback_price=current_price
)
except Exception as e:
logger.error(
f"Trades:update() unable to resolve executed price for trade {trade_id}: {e}",
exc_info=True
)
continue
if executed_price <= 0:
logger.error(
f"Trades:update() received non-positive executed price for trade {trade_id}: "
f"{executed_price}"
)
continue
trade.trade_filled(qty=executed_qty, price=executed_price)
elif status in ['CANCELED', 'EXPIRED', 'REJECTED']:
logger.warning(f"Trade {trade_id} status: {status}")
# Handle according to your business logic, e.g., mark as closed or retry
trade.status = status.lower()
continue # Skip further processing for this trade
_debug_logger.debug(f"Checking if trade.status == 'inactive': {trade.status == 'inactive'}")
if trade.status == 'inactive':
_debug_logger.debug(f"Trade {trade_id} is inactive, skipping")
logger.error(f"Trades:update() - inactive trade encountered: {trade_id}")
continue # Skip processing for inactive trades
_debug_logger.debug(f"Calling trade.update({current_price})")
trade.update(current_price)
trade_status = trade.status
_debug_logger.debug(f"After trade.update(), trade_status={trade_status}, trade.stats={trade.stats}")
if trade_status in ['updated', 'filled', 'part-filled']:
update_data = {
'status': trade_status,
'id': trade.unique_id,
'pl': trade.stats.get('profit', 0.0),
'pl_pct': trade.stats.get('profit_pct', 0.0)
}
r_update.append(update_data)
_debug_logger.debug(f"Appended update_data: {update_data}")
logger.info(f"Trade {trade_id} updated: price={current_price}, P/L={update_data['pl']:.2f} ({update_data['pl_pct']:.2f}%)")
else:
_debug_logger.debug(f"trade_status '{trade_status}' not in update list, appending minimal data")
r_update.append({'id': trade.unique_id, 'status': trade_status})
_debug_logger.debug(f"=== Trades.update() returning: {r_update} ===")
return r_update
def update_prices_only(self, price_updates: dict[str, float]) -> list[dict[str, Any]]:
"""
Update current prices and P/L for active trades.
This method ONLY updates prices and P/L calculations. It does NOT poll brokers
for order status - that happens in strategy_execution_loop via ManualTradingBrokerManager.
:param price_updates: Dictionary mapping (exchange:symbol) or symbol to prices.
:return: List of dictionaries containing updated trade data.
"""
r_update = []
for trade_id, trade in list(self.active_trades.items()):
symbol = trade.symbol
exchange = getattr(trade, 'exchange', None) or trade.target
# Resolve price from updates
exchange_key = f"{exchange.lower()}:{symbol}" if exchange else None
current_price = price_updates.get(exchange_key) if exchange_key else None
# DEBUG: Log price resolution for live trades
if trade.broker_kind == 'live':
logger.info(f"[PRICE DEBUG] trade={trade_id[:8]}, exchange={exchange}, "
f"exchange_key={exchange_key}, price_updates_keys={list(price_updates.keys())}, "
f"found_price={current_price}")
if current_price is None:
current_price = price_updates.get(symbol)
if current_price is None:
# Try to find a matching symbol
for price_key, price in price_updates.items():
price_symbol = price_key.split(':')[-1] if ':' in price_key else price_key
norm_trade = symbol.upper().replace('/', '')
norm_price = price_symbol.upper().replace('/', '')
if norm_trade == norm_price or norm_trade.rstrip('T') == norm_price.rstrip('T'):
current_price = price
break
if current_price is None:
continue # No price available for this trade
# Skip inactive or closed trades
if trade.status in ['inactive', 'closed']:
continue
# Update P/L values (no broker polling)
trade.update(current_price)
trade_status = trade.status
if trade_status in ['updated', 'filled', 'part-filled']:
# Compute broker_key for frontend position matching
broker_key = 'paper' if trade.broker_kind == 'paper' else f"{trade.broker_exchange}_{trade.broker_mode}"
update_data = {
'status': trade_status,
'id': trade.unique_id,
'symbol': trade.symbol,
'broker_key': broker_key,
'pl': trade.stats.get('profit', 0.0),
'pl_pct': trade.stats.get('profit_pct', 0.0),
'current_price': current_price
}
r_update.append(update_data)
else:
broker_key = 'paper' if trade.broker_kind == 'paper' else f"{trade.broker_exchange}_{trade.broker_mode}"
r_update.append({'id': trade.unique_id, 'status': trade_status, 'symbol': trade.symbol, 'broker_key': broker_key})
return r_update
def close_trade(self, trade_id: str, current_price: float = None) -> dict:
"""
Closes a specific trade by settling it.
:param trade_id: The unique ID of the trade.
:param current_price: Optional current price (used for paper trades).
:return: Dict with success status and trade info.
"""
trade = self.get_trade_by_id(trade_id)
if not trade:
logger.error(f"close_trade(): Trade ID {trade_id} not found.")
return {"success": False, "message": f"Trade {trade_id} not found."}
if trade.status == 'closed':
logger.warning(f"close_trade(): Trade ID {trade_id} is already closed.")
return {"success": False, "message": f"Trade {trade_id} is already closed."}
try:
# Get current price
if current_price is None:
if trade.is_paper:
# For paper trades without a price, use the last known current price
current_price = trade.stats.get('current_price', trade.order_price)
elif self.exchange_interface:
current_price = self.exchange_interface.get_price(trade.symbol)
else:
current_price = trade.stats.get('current_price', trade.order_price)
# Settle the trade
trade.settle(qty=trade.base_order_qty, price=current_price)
# Calculate final P/L
final_pl = trade.stats.get('profit', 0.0)
final_pl_pct = trade.stats.get('profit_pct', 0.0)
# Move from active to settled
if trade.status == 'closed':
del self.active_trades[trade_id]
self.settled_trades[trade_id] = trade
self.stats['num_trades'] -= 1
# Update database - either delete or mark as closed
if self.data_cache:
self._save_trade(trade)
logger.info(f"Trade {trade_id} closed. P/L: {final_pl:.2f} ({final_pl_pct:.2f}%)")
return {
"success": True,
"message": "Trade closed successfully.",
"trade_id": trade_id,
"final_pl": final_pl,
"final_pl_pct": final_pl_pct,
"settled_price": current_price
}
else:
# Partial settlement
self._save_trade(trade)
logger.info(f"Trade {trade_id} partially settled.")
return {
"success": True,
"message": "Trade partially settled.",
"trade_id": trade_id
}
except Exception as e:
logger.error(f"Error closing trade '{trade_id}': {e}", exc_info=True)
return {"success": False, "message": f"Error closing trade: {str(e)}"}
def close_position(self, user_id: int, symbol: str, broker_key: str) -> dict:
"""
Close filled exposure for a symbol (position-first operation).
This only affects filled/part-filled trades:
- Fully filled trades: settle entire qty with P/L calculation
- Part-filled trades: settle filled portion, cancel unfilled remainder
- Pending/open orders: NOT affected (use cancel_orders_for_symbol for those)
:param user_id: The user ID.
:param symbol: Trading symbol to close.
:param broker_key: The broker key ('paper' or 'exchange_production').
:return: Dict with success status and details.
"""
if not self.manual_broker_manager:
return {"success": False, "message": "Broker manager not configured"}
result = self.manual_broker_manager.close_position(user_id, symbol, broker_key)
if result.get('success'):
close_status = str(result.get('status') or '').lower()
close_price = result.get('filled_price', 0.0)
trades_closed = 0
closed_trade_ids = []
# Live close-position requests may place a market order that is still pending/open.
# Only settle/remove the local trade immediately if the broker reports it filled.
if close_status not in ['', 'filled', 'partially_filled']:
result['trades_closed'] = 0
result['closed_trades'] = []
result['message'] = result.get('message') or 'Close order submitted.'
return result
for trade_id, trade in list(self.active_trades.items()):
# Check if this trade belongs to the same broker
if broker_key == 'paper':
matches_broker = trade.broker_kind == 'paper'
else:
trade_broker_key = f"{trade.broker_exchange}_{trade.broker_mode}"
matches_broker = (trade.broker_kind == 'live' and trade_broker_key == broker_key)
if not (trade.symbol == symbol and matches_broker and trade.creator == user_id):
continue
# Handle based on trade status
if trade.status == 'filled':
# Fully filled - settle entire qty
if close_price <= 0:
close_price = self._get_close_price(trade)
trade.settle(qty=trade.stats.get('qty_filled', trade.base_order_qty), price=close_price)
trade.status = 'closed'
trades_closed += 1
elif trade.status == 'part-filled':
# Part filled - settle only the filled portion, cancel the rest
if close_price <= 0:
close_price = self._get_close_price(trade)
filled_qty = trade.stats.get('qty_filled', 0)
if filled_qty > 0:
trade.settle(qty=filled_qty, price=close_price)
# Cancel the unfilled remainder through broker
if trade.broker_order_id:
self.manual_broker_manager.cancel_order(
user_id, trade.broker_order_id, broker_key
)
trade.status = 'closed'
unfilled = trade.base_order_qty - filled_qty
logger.info(f"Part-filled trade {trade_id}: settled {filled_qty}, "
f"cancelled remainder of {unfilled}")
trades_closed += 1
elif trade.status in ['pending', 'open', 'unfilled']:
# No fills - skip (these are just resting orders, not positions)
continue
else:
# Already closed/cancelled - skip
continue
# Save and move to settled
self._save_trade(trade)
del self.active_trades[trade_id]
self.settled_trades[trade_id] = trade
self.stats['num_trades'] -= 1
closed_trade_ids.append(trade_id)
final_pl = trade.stats.get('profit', 0.0)
logger.info(f"Trade {trade_id} closed via position close. P/L: {final_pl:.2f}")
result['trades_closed'] = trades_closed
result['closed_trades'] = closed_trade_ids
return result
def _get_close_price(self, trade) -> float:
"""Get current price for settlement."""
if self.exchange_interface:
try:
return self.exchange_interface.get_price(trade.symbol)
except Exception:
pass
return trade.stats.get('current_price', trade.order_price)
def cancel_order(self, trade_id: str) -> dict:
"""
Cancel a specific unfilled order.
:param trade_id: The unique ID of the trade/order to cancel.
:return: Dict with success status and message.
"""
trade = self.get_trade_by_id(trade_id)
if not trade:
return {"success": False, "message": "Trade not found"}
if trade.status not in ['open', 'pending', 'unfilled']:
return {"success": False, "message": "Cannot cancel: order already filled or closed"}
# If using broker manager, cancel through it
if self.manual_broker_manager and trade.broker_order_id:
broker_key = 'paper' if trade.broker_kind == 'paper' else f"{trade.broker_exchange}_{trade.broker_mode}"
result = self.manual_broker_manager.cancel_order(
trade.creator, trade.broker_order_id, broker_key
)
if not result.get('success'):
return result
trade.status = 'cancelled'
self._save_trade(trade)
# Move from active trades to settled trades (so it appears in history)
if trade_id in self.active_trades:
del self.active_trades[trade_id]
self.settled_trades[trade_id] = trade
self.stats['num_trades'] -= 1
logger.info(f"Order {trade_id} cancelled")
return {"success": True, "message": "Order cancelled"}
def cancel_orders_for_symbol(self, user_id: int, symbol: str, broker_key: str) -> dict:
"""
Cancel all open/pending orders for a symbol.
This is a separate action from close_position() - user must explicitly choose this.
Does NOT affect filled positions.
:param user_id: The user ID.
:param symbol: Trading symbol.
:param broker_key: The broker key ('paper' or 'exchange_mode').
:return: Dict with success status, message, and count.
"""
cancelled = 0
errors = []
for trade_id, trade in list(self.active_trades.items()):
# Check broker match
if broker_key == 'paper':
matches_broker = trade.broker_kind == 'paper'
else:
trade_broker_key = f"{trade.broker_exchange}_{trade.broker_mode}"
matches_broker = (trade.broker_kind == 'live' and trade_broker_key == broker_key)
if not (trade.symbol == symbol and matches_broker and trade.creator == user_id):
continue
# Only cancel unfilled orders
if trade.status not in ['pending', 'open', 'unfilled']:
continue
result = self.cancel_order(trade_id)
if result.get('success'):
cancelled += 1
else:
errors.append(f"{trade_id}: {result.get('message')}")
if errors:
return {
"success": False,
"message": f"Cancelled {cancelled}, errors: {'; '.join(errors)}",
"count": cancelled
}
return {"success": True, "message": f"Cancelled {cancelled} orders", "count": cancelled}
def reduce_trade(self, user_id: int, trade_id: str, qty: float) -> float | None:
"""
Reduces the position of a trade.
:param user_id: ID of the user.
:param trade_id: The unique ID of the trade.
:param qty: The quantity to reduce the trade by.
:return: The remaining quantity after reduction or None if unsuccessful.
"""
trade = self.get_trade_by_id(trade_id)
if not trade:
logger.error(f"reduce_trade(): Trade ID {trade_id} not found.")
return None
if trade.status == 'closed':
logger.warning(f"reduce_trade(): Cannot reduce a closed trade {trade_id}.")
return None
if trade.status in ['inactive', 'unfilled']:
if trade.status == 'unfilled' and trade.order:
try:
self.exchange_interface.cancel_order(symbol=trade.symbol, orderId=trade.order.orderId)
logger.info(f"Cancelled unfilled order {trade.unique_id} for trade {trade_id}.")
except Exception as e:
logger.error(f"Failed to cancel order {trade.unique_id} for trade {trade_id}: {e}", exc_info=True)
return None
# Reduce the order quantity
if qty > trade.base_order_qty:
qty = trade.base_order_qty
trade.base_order_qty -= qty
trade.stats['opening_value'] = trade.base_order_qty * trade.stats['opening_price']
trade.update_values(trade.stats['opening_price'])
logger.info(f"Trade {trade_id} reduced by {qty}. New quantity: {trade.base_order_qty}")
# If trade was unfilled, attempt to place a new order with the reduced quantity
if trade.status == 'unfilled':
try:
status, msg = self.place_order(trade, user_name=self._get_user_name(user_id))
if status != 'success':
logger.error(f"Failed to place reduced order for trade {trade_id}: {msg}")
return None
except Exception as e:
logger.error(f"Exception while placing reduced order for trade {trade_id}: {e}", exc_info=True)
return None
return trade.base_order_qty
# Settling more than owned is not allowed
if qty > trade.stats.get('qty_filled', 0.0):
qty = trade.stats.get('qty_filled', 0.0)
try:
current_price = self.exchange_interface.get_price(trade.symbol)
trade.settle(qty=qty, price=current_price)
if trade.status == 'closed':
del self.active_trades[trade_id]
self.settled_trades[trade_id] = trade
self.stats['num_trades'] -= 1
logger.info(f"Trade {trade_id} has been closed after reduction.")
return 0.0
else:
left = trade.stats['qty_filled'] - trade.stats['qty_settled']
logger.info(f"Trade {trade_id} partially settled. Remaining quantity: {left}")
return float(f"{left:.3f}")
except Exception as e:
logger.error(f"Error settling trade '{trade_id}': {e}", exc_info=True)
return None
def find_trade_by_broker_order_id(self, broker_order_id: str) -> Trade | None:
"""
Find a trade by its broker order ID.
:param broker_order_id: The broker order ID to search for.
:return: Trade object if found, None otherwise.
"""
for trade in self.active_trades.values():
if trade.broker_order_id == broker_order_id:
return trade
return None
def recover_brokers(self) -> int:
"""
Recover brokers for broker-managed trades after restart.
This should be called after manual_broker_manager is wired up.
It ensures that persisted broker-managed trades have their brokers
recreated so they can be polled and tracked.
:return: Number of brokers recovered.
"""
if not self.manual_broker_manager:
logger.debug("No broker manager configured, skipping broker recovery")
return 0
# Get trades that have broker_order_id (broker-managed)
broker_trades = [
trade for trade in self.active_trades.values()
if trade.broker_order_id
]
if not broker_trades:
return 0
# Use users to get username from user_id
def get_username(user_id):
return self._get_user_name(user_id)
recovered = self.manual_broker_manager.recover_brokers_for_trades(
trades=broker_trades,
get_username_func=get_username
)
logger.info(f"Recovered {recovered} brokers for {len(broker_trades)} broker-managed trades")
return recovered