1845 lines
79 KiB
Python
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
|