diff --git a/VERSION b/VERSION
index 227cea21..38f77a65 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-2.0.0
+2.0.1
diff --git a/alembic/versions/a62763117ed4_add_fund_ledger_system.py b/alembic/versions/a62763117ed4_add_fund_ledger_system.py
new file mode 100644
index 00000000..2b7728ca
--- /dev/null
+++ b/alembic/versions/a62763117ed4_add_fund_ledger_system.py
@@ -0,0 +1,92 @@
+"""add_fund_ledger_system
+
+Revision ID: a62763117ed4
+Revises: 762a64d64b9d
+Create Date: 2026-02-25 16:21:58.440948
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision: str = 'a62763117ed4'
+down_revision: Union[str, None] = '762a64d64b9d'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('fund_ledger',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('user_id', sa.Integer(), nullable=False),
+ sa.Column('trading_mode', sa.String(length=20), nullable=False),
+ sa.Column('transaction_type', sa.String(length=10), nullable=False),
+ sa.Column('category', sa.String(length=50), nullable=False),
+ sa.Column('amount', sa.Numeric(precision=15, scale=2), nullable=False),
+ sa.Column('balance_before', sa.Numeric(precision=15, scale=2), nullable=False),
+ sa.Column('balance_after', sa.Numeric(precision=15, scale=2), nullable=False),
+ sa.Column('description', sa.String(length=255), nullable=True),
+ sa.Column('reference_id', sa.String(length=100), nullable=True),
+ sa.Column('timestamp', sa.DateTime(), nullable=True),
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index('idx_ledger_user_mode', 'fund_ledger', ['user_id', 'trading_mode'], unique=False)
+ op.create_index(op.f('ix_fund_ledger_category'), 'fund_ledger', ['category'], unique=False)
+ op.create_index(op.f('ix_fund_ledger_id'), 'fund_ledger', ['id'], unique=False)
+ op.create_index(op.f('ix_fund_ledger_reference_id'), 'fund_ledger', ['reference_id'], unique=False)
+ op.create_index(op.f('ix_fund_ledger_timestamp'), 'fund_ledger', ['timestamp'], unique=False)
+ op.create_index(op.f('ix_fund_ledger_trading_mode'), 'fund_ledger', ['trading_mode'], unique=False)
+ op.create_index(op.f('ix_fund_ledger_user_id'), 'fund_ledger', ['user_id'], unique=False)
+ op.drop_index('idx_trading_log_level_component', table_name='trading_system_logs')
+ op.drop_index('idx_trading_log_timestamp', table_name='trading_system_logs')
+ op.drop_index('ix_trading_system_logs_component', table_name='trading_system_logs')
+ op.drop_index('ix_trading_system_logs_id', table_name='trading_system_logs')
+ op.drop_index('ix_trading_system_logs_log_level', table_name='trading_system_logs')
+ op.drop_index('ix_trading_system_logs_timestamp', table_name='trading_system_logs')
+ op.drop_index('ix_trading_system_logs_trade_id', table_name='trading_system_logs')
+ op.drop_index('ix_trading_system_logs_user_id', table_name='trading_system_logs')
+ op.drop_table('trading_system_logs')
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('trading_system_logs',
+ sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
+ sa.Column('log_level', sa.VARCHAR(length=20), autoincrement=False, nullable=False),
+ sa.Column('component', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
+ sa.Column('message', sa.TEXT(), autoincrement=False, nullable=False),
+ sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=True),
+ sa.Column('trade_id', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
+ sa.Column('symbol', sa.VARCHAR(length=20), autoincrement=False, nullable=True),
+ sa.Column('latency_ms', sa.INTEGER(), autoincrement=False, nullable=True),
+ sa.Column('function_name', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
+ sa.Column('line_number', sa.INTEGER(), autoincrement=False, nullable=True),
+ sa.Column('stack_trace', sa.TEXT(), autoincrement=False, nullable=True),
+ sa.Column('additional_data', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=True),
+ sa.Column('timestamp', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], name='trading_system_logs_user_id_fkey', ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id', name='trading_system_logs_pkey')
+ )
+ op.create_index('ix_trading_system_logs_user_id', 'trading_system_logs', ['user_id'], unique=False)
+ op.create_index('ix_trading_system_logs_trade_id', 'trading_system_logs', ['trade_id'], unique=False)
+ op.create_index('ix_trading_system_logs_timestamp', 'trading_system_logs', ['timestamp'], unique=False)
+ op.create_index('ix_trading_system_logs_log_level', 'trading_system_logs', ['log_level'], unique=False)
+ op.create_index('ix_trading_system_logs_id', 'trading_system_logs', ['id'], unique=False)
+ op.create_index('ix_trading_system_logs_component', 'trading_system_logs', ['component'], unique=False)
+ op.create_index('idx_trading_log_timestamp', 'trading_system_logs', ['timestamp'], unique=False)
+ op.create_index('idx_trading_log_level_component', 'trading_system_logs', ['log_level', 'component'], unique=False)
+ op.drop_index(op.f('ix_fund_ledger_user_id'), table_name='fund_ledger')
+ op.drop_index(op.f('ix_fund_ledger_trading_mode'), table_name='fund_ledger')
+ op.drop_index(op.f('ix_fund_ledger_timestamp'), table_name='fund_ledger')
+ op.drop_index(op.f('ix_fund_ledger_reference_id'), table_name='fund_ledger')
+ op.drop_index(op.f('ix_fund_ledger_id'), table_name='fund_ledger')
+ op.drop_index(op.f('ix_fund_ledger_category'), table_name='fund_ledger')
+ op.drop_index('idx_ledger_user_mode', table_name='fund_ledger')
+ op.drop_table('fund_ledger')
+ # ### end Alembic commands ###
diff --git a/database/models.py b/database/models.py
index 5a4129a1..5b76caa3 100644
--- a/database/models.py
+++ b/database/models.py
@@ -1485,6 +1485,42 @@ class TradingAuditTrail(Base):
)
+class FundLedger(Base):
+ """
+ Transaction ledger for tracking all fund movements (Credits/Debits)
+ Behaves like a real bank/brokerage statement.
+ """
+ __tablename__ = "fund_ledger"
+
+ id = Column(Integer, primary_key=True, index=True)
+ user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False)
+
+ # paper or live
+ trading_mode = Column(String(20), nullable=False, index=True)
+
+ # CREDIT (Money coming in) or DEBIT (Money going out)
+ transaction_type = Column(String(10), nullable=False)
+
+ # FUND_ADDED, TRADE_MARGIN_BLOCKED, TRADE_MARGIN_RELEASED, PNL_SETTLEMENT, CHARGES, BROKERAGE
+ category = Column(String(50), nullable=False, index=True)
+
+ amount = Column(Numeric(15, 2), nullable=False)
+ balance_before = Column(Numeric(15, 2), nullable=False)
+ balance_after = Column(Numeric(15, 2), nullable=False)
+
+ description = Column(String(255))
+ reference_id = Column(String(100), index=True) # trade_id or transaction_id
+
+ timestamp = Column(DateTime, default=get_ist_now_naive, index=True)
+
+ # Relationships
+ user = relationship("User")
+
+ __table_args__ = (
+ Index("idx_ledger_user_mode", "user_id", "trading_mode"),
+ )
+
+
# =======================
# PREMARKET CANDLE & GAP DETECTION SYSTEM
# =======================
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index a08feaa7..b285bf1a 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -1,6 +1,29 @@
-# 📝 Changelog
+# 📠Changelog
All notable changes to this project will be documented in this file.
-## [Unreleased]
-- Ongoing improvements and bug fixes.
+## [2.0.1] - 2026-02-25
+### Added
+- **AI-Powered Development Agents:**
+ - Automated Issue Triager for bug analysis using Gemini.
+ - Automated PR Reviewer for code quality, complexity (radon), and risk checks.
+ - Automated Backtesting for strategy performance validation in pull requests.
+ - AI Documentation Agent for automated semantic changelogs.
+ - Trading Risk Guard for detecting dangerous code patterns (hardcoded secrets, unlocalized time).
+- **Fund Ledger System:**
+ - Database-backed fund ledger tracking all deposits and withdrawals.
+ - Integration with `CapitalManager` for accurate balance management across brokers.
+ - New UI components: `AddFundsModal`, `FundStatementTable`, and `FundsTab` in profile.
+- **AI Support & Telegram Integration:**
+ - `AISupportService` for context-aware support answering queries using project docs and trade history.
+ - Telegram bot for remote support and account status tracking.
+
+### Changed
+- Refactored `execution_handler.py` and `pnl_tracker.py` to utilize the new `FundManager` for balance synchronization.
+- Enhanced `AutoTradingPage` for better performance and list virtualization.
+- Updated `requirements.txt` with `google-generativeai` and `PyGithub`.
+
+### Fixed
+- Improved timezone handling for IST consistency across services.
+- Corrected paper trading P&L logic for accurate balance updates.
+- Refined stock selection direction logic for neutral market conditions.
diff --git a/router/trading_execution_router.py b/router/trading_execution_router.py
index b1242b23..ec296ae8 100644
--- a/router/trading_execution_router.py
+++ b/router/trading_execution_router.py
@@ -35,6 +35,7 @@
from services.trading_execution.multi_demat_capital_service import (
multi_demat_capital_service,
)
+from services.trading_execution.fund_manager import fund_manager
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
@@ -1881,3 +1882,50 @@ async def get_detailed_performance(
except Exception as e:
logger.error(f"Error getting detailed performance: {e}")
raise HTTPException(status_code=500, detail=str(e))
+
+
+# ==================== FUND MANAGEMENT ENDPOINTS ====================
+
+@router.get("/funds/balance")
+async def get_fund_balances(
+ trading_mode: str = Query("paper", description="paper or live"),
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ """Get available, used and total balance"""
+ try:
+ balances = fund_manager.get_balances(current_user.id, db, trading_mode)
+ return {"success": True, "balances": balances}
+ except Exception as e:
+ logger.error(f"Error fetching balances: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+@router.post("/funds/add-paper-funds")
+async def add_paper_funds(
+ amount: float = Query(..., gt=0, description="Amount to add"),
+ description: str = Query("Manual top-up", description="Transaction description"),
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ """Add virtual funds to paper trading account"""
+ try:
+ result = fund_manager.add_paper_funds(current_user.id, amount, db, description)
+ return result
+ except Exception as e:
+ logger.error(f"Error adding funds: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+@router.get("/funds/statement")
+async def get_fund_statement(
+ trading_mode: str = Query("paper", description="paper or live"),
+ limit: int = Query(50, description="Max entries to return"),
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ """Get transaction ledger/statement"""
+ try:
+ statement = fund_manager.get_statement(current_user.id, db, trading_mode, limit)
+ return {"success": True, "statement": statement}
+ except Exception as e:
+ logger.error(f"Error fetching statement: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
diff --git a/services/paper_trading_account.py b/services/paper_trading_account.py
index cda588fc..b8ec6e1a 100644
--- a/services/paper_trading_account.py
+++ b/services/paper_trading_account.py
@@ -116,18 +116,8 @@ async def sync_with_db(self, user_id: int, db: Session) -> Optional[PaperAccount
db_account = db.query(PaperTradingAccount).filter(PaperTradingAccount.user_id == user_id).first()
if not db_account:
- # Create default in DB if not exists
- db_account = PaperTradingAccount(
- user_id=user_id,
- initial_capital=100000.0,
- current_balance=100000.0,
- available_margin=100000.0,
- used_margin=0.0,
- total_pnl=0.0
- )
- db.add(db_account)
- db.commit()
- db.refresh(db_account)
+ logger.warning(f"No paper account found in DB for user {user_id}. Fund management required.")
+ return None
# Update in-memory
account = PaperAccount(
@@ -163,16 +153,7 @@ def execute_paper_trade_sync(self, user_id: int, trade_data: Dict[str, Any], db:
db_acc = db.query(PaperTradingAccount).filter(PaperTradingAccount.user_id == user_id).first()
if not db_acc:
- db_acc = PaperTradingAccount(
- user_id=user_id,
- initial_capital=100000.0,
- current_balance=100000.0,
- available_margin=100000.0,
- used_margin=0.0,
- total_pnl=0.0
- )
- db.add(db_acc)
- db.flush()
+ raise ValueError(f"No paper trading account found for user {user_id}. Please add funds first.")
invested_amount = float(trade_data['invested_amount'])
entry_charges = 20.0
diff --git a/services/trading_execution/capital_manager.py b/services/trading_execution/capital_manager.py
index a1dfcda4..3cba1044 100644
--- a/services/trading_execution/capital_manager.py
+++ b/services/trading_execution/capital_manager.py
@@ -49,7 +49,6 @@ class TradingCapitalManager:
def __init__(self):
"""Initialize capital manager with configuration"""
- self.paper_trading_capital = Decimal('100000') # 1 Lakh default for paper trading
self.max_capital_per_trade_percent = Decimal('0.60') # 60% max per trade
self.max_risk_per_trade_percent = Decimal('0.02') # 2% max risk per trade
self.min_capital_buffer = Decimal('0.10') # Keep 10% buffer
@@ -133,53 +132,16 @@ def get_available_capital(
trading_mode: TradingMode = TradingMode.PAPER
) -> Decimal:
"""
- Get available capital for trading
+ Get available capital for trading using centralized Fund Manager
"""
if not user_id or user_id <= 0:
raise ValueError("Invalid user_id provided")
try:
- if trading_mode == TradingMode.PAPER:
- # FIX 3: Paper Trading Capital Source handled safely
- from services.paper_trading_account import paper_trading_service, PaperAccount
- account = paper_trading_service.accounts.get(user_id)
-
- if not account:
- # Sync with DB synchronously
- from database.models import PaperTradingAccount
- db_account = db.query(PaperTradingAccount).filter(PaperTradingAccount.user_id == user_id).first()
- if db_account:
- account = PaperAccount(
- user_id=db_account.user_id,
- initial_capital=float(db_account.initial_capital),
- current_balance=float(db_account.current_balance),
- used_margin=float(db_account.used_margin),
- available_margin=float(db_account.available_margin),
- total_pnl=float(db_account.total_pnl),
- daily_pnl=float(db_account.daily_pnl),
- positions_count=db_account.positions_count
- )
- paper_trading_service.accounts[user_id] = account
-
- if account:
- total_capital = Decimal(str(account.available_margin)) + Decimal(str(account.used_margin))
- else:
- logger.warning(f"Paper account not found for user {user_id}. Falling back to default capital.")
- total_capital = self.paper_trading_capital
- else:
- broker_config = self.get_active_broker_config(user_id, db)
- if not broker_config:
- self._update_function_health("available_capital", "error", "No active broker config")
- return Decimal('0')
-
- available_margin, used_margin = self._fetch_funds_from_broker(broker_config)
- # For Live trading, available_margin already accounts for used margin
- # We return it directly as the 'available' cash
- return max(Decimal('0'), available_margin)
-
- # For Paper Trading only: JOIN check to ensure only truly active positions consume capital
- allocated_capital = self._get_allocated_capital_for_active_positions(user_id, db)
- available_capital = total_capital - allocated_capital
+ from services.trading_execution.fund_manager import fund_manager
+ balances = fund_manager.get_balances(user_id, db, trading_mode.value)
+
+ available_capital = Decimal(str(balances.get("available_margin", 0)))
# FIX 5: Health tracking
self._update_function_health("available_capital", "success")
@@ -197,46 +159,15 @@ def get_available_capital_for_new_position(
trading_mode: TradingMode = TradingMode.PAPER
) -> Decimal:
"""
- Get capital available for opening a NEW position
+ Get capital available for opening a NEW position using centralized Fund Manager
"""
if not user_id or user_id <= 0:
raise ValueError("Invalid user_id provided")
try:
- if trading_mode == TradingMode.PAPER:
- # FIX 3: Paper Trading Capital Source handled safely
- from services.paper_trading_account import paper_trading_service, PaperAccount
- account = paper_trading_service.accounts.get(user_id)
-
- if not account:
- # Sync with DB synchronously
- from database.models import PaperTradingAccount
- db_account = db.query(PaperTradingAccount).filter(PaperTradingAccount.user_id == user_id).first()
- if db_account:
- account = PaperAccount(
- user_id=db_account.user_id,
- initial_capital=float(db_account.initial_capital),
- current_balance=float(db_account.current_balance),
- used_margin=float(db_account.used_margin),
- available_margin=float(db_account.available_margin),
- total_pnl=float(db_account.total_pnl),
- daily_pnl=float(db_account.daily_pnl),
- positions_count=db_account.positions_count
- )
- paper_trading_service.accounts[user_id] = account
-
- if account:
- total_capital = Decimal(str(account.available_margin)) + Decimal(str(account.used_margin))
- else:
- logger.warning(f"Paper account not found for user {user_id}. Falling back to default capital.")
- total_capital = self.paper_trading_capital
- else:
- broker_config = self.get_active_broker_config(user_id, db)
- if not broker_config:
- return Decimal('0')
- available_margin, used_margin = self._fetch_funds_from_broker(broker_config)
- # For Live, the broker API gives us free cash directly as available_margin
- total_capital = available_margin
+ from services.trading_execution.fund_manager import fund_manager
+ balances = fund_manager.get_balances(user_id, db, trading_mode.value)
+ total_capital = Decimal(str(balances.get("available_margin", 0)))
# Check concurrent position limit
active_positions_count = db.query(ActivePosition).filter(
@@ -263,22 +194,14 @@ def get_total_account_size(
trading_mode: TradingMode = TradingMode.PAPER
) -> Decimal:
"""
- Get the total base capital of the account (Available + Used)
+ Get the total base capital of the account (Available + Used) using centralized Fund Manager
Used for risk-per-trade percentage calculations
"""
try:
- if trading_mode == TradingMode.PAPER:
- from services.paper_trading_account import paper_trading_service
- account = paper_trading_service.accounts.get(user_id)
- if account:
- return Decimal(str(account.available_margin)) + Decimal(str(account.used_margin))
- return self.paper_trading_capital
- else:
- broker_config = self.get_active_broker_config(user_id, db)
- if not broker_config:
- return Decimal('0')
- available, used = self._fetch_funds_from_broker(broker_config)
- return available + used
+ from services.trading_execution.fund_manager import fund_manager
+ balances = fund_manager.get_balances(user_id, db, trading_mode.value)
+
+ return Decimal(str(balances.get("current_balance", 0)))
except Exception:
return Decimal('0')
@@ -507,29 +430,18 @@ def get_capital_utilization_summary(
trading_mode: TradingMode = TradingMode.PAPER
) -> Dict[str, Any]:
"""
- Get comprehensive capital utilization summary for a user
+ Get comprehensive capital utilization summary for a user using centralized Fund Manager
"""
if not user_id or user_id <= 0:
raise ValueError("Invalid user_id provided")
try:
- if trading_mode == TradingMode.PAPER:
- from services.paper_trading_account import paper_trading_service
- account = paper_trading_service.accounts.get(user_id)
- if account:
- total_capital = Decimal(str(account.available_margin)) + Decimal(str(account.used_margin))
- else:
- total_capital = self.paper_trading_capital
- else:
- broker_config = self.get_active_broker_config(user_id, db)
- if broker_config:
- available, used = self._fetch_funds_from_broker(broker_config)
- total_capital = available + used
- else:
- total_capital = Decimal('0')
-
- allocated_capital = self._get_allocated_capital_for_active_positions(user_id, db)
- available_capital = total_capital - allocated_capital
+ from services.trading_execution.fund_manager import fund_manager
+ balances = fund_manager.get_balances(user_id, db, trading_mode.value)
+
+ total_capital = Decimal(str(balances.get("current_balance", 0)))
+ available_capital = Decimal(str(balances.get("available_margin", 0)))
+ allocated_capital = Decimal(str(balances.get("used_margin", 0)))
active_positions_count = db.query(ActivePosition).filter(
ActivePosition.user_id == user_id,
diff --git a/services/trading_execution/execution_handler.py b/services/trading_execution/execution_handler.py
index a59d8148..6f6bb2f5 100644
--- a/services/trading_execution/execution_handler.py
+++ b/services/trading_execution/execution_handler.py
@@ -15,6 +15,7 @@
from database.models import AutoTradeExecution, ActivePosition, User, BrokerConfig
from services.trading_execution.capital_manager import TradingMode
from services.trading_execution.trade_prep import PreparedTrade, TradeStatus
+from services.trading_execution.fund_manager import fund_manager
from utils.timezone_utils import get_ist_now_naive, get_ist_isoformat
from utils.logging_utils import log_structured, log_trade_result
from services.notifications.telegram_service import telegram_notifier
@@ -230,37 +231,17 @@ def _execute_paper_trade(
user_id=str(prepared_trade.user_id)
)
- # UPDATE PAPER TRADING ACCOUNT BALANCE (Sync with in-memory service and DB)
- try:
- from services.paper_trading_account import paper_trading_service
-
- trade_data = {
- "symbol": prepared_trade.stock_symbol,
- "instrument_key": prepared_trade.option_instrument_key,
- "option_type": prepared_trade.option_type,
- "strike_price": float(prepared_trade.strike_price),
- "entry_price": float(entry_price),
- "quantity": quantity,
- "lot_size": prepared_trade.lot_size,
- "invested_amount": float(total_investment),
- "stop_loss": float(prepared_trade.stop_loss),
- "target": float(prepared_trade.target_price)
- }
-
- # Use synchronous method as we are in a synchronous function
- paper_trading_service.execute_paper_trade_sync(
- user_id=prepared_trade.user_id,
- trade_data=trade_data,
- db=db
- )
-
- # Fetch updated account for logging (from in-memory)
- account = paper_trading_service.accounts.get(prepared_trade.user_id)
- if account:
- logger.info(f"✅ Paper account updated: Balance={account.current_balance:,.2f}, Used={account.used_margin:,.2f}")
-
- except Exception as e:
- logger.error(f"Failed to update paper trading account balance: {e}")
+ # BLOCK MARGIN & CREATE LEDGER ENTRY (New Fund Management System)
+ margin_blocked = fund_manager.block_margin(
+ user_id=prepared_trade.user_id,
+ amount=float(total_investment),
+ trading_mode="paper",
+ reference_id=trade_id,
+ db=db
+ )
+
+ if not margin_blocked:
+ raise ValueError("Insufficient funds to block margin for this trade")
# Create trade execution record
trade_execution = AutoTradeExecution(
@@ -403,7 +384,7 @@ def _execute_live_trade(
trade_id = f"LIVE_{uuid.uuid4().hex[:12].upper()}"
# Place order via broker
- order_result = self._place_broker_order(broker_config, prepared_trade)
+ order_result = self._place_broker_order(broker_config, prepared_trade, db)
if not order_result.get("success"):
raise ValueError(
@@ -695,7 +676,7 @@ def _close_paper_position(self, db: Session, position: ActivePosition, trade: Au
db.commit()
def _place_broker_order(
- self, broker_config: BrokerConfig, prepared_trade: PreparedTrade
+ self, broker_config: BrokerConfig, prepared_trade: PreparedTrade, db: Session
) -> Dict[str, Any]:
"""
Place order via broker API
@@ -752,6 +733,20 @@ def _place_broker_order(
f"latency: {latency}ms, IDs: {order_ids}"
)
+ # Calculate total investment for ledger
+ actual_price = Decimal(str(prepared_trade.entry_price))
+ total_investment = actual_price * Decimal(str(quantity))
+ trade_id = f"LIVE_{primary_order_id}"
+
+ # Record margin blocking in ledger for live trade (informational)
+ fund_manager.block_margin(
+ user_id=prepared_trade.user_id,
+ amount=float(total_investment),
+ trading_mode="live",
+ reference_id=trade_id,
+ db=db
+ )
+
return {
"success": True,
"order_id": primary_order_id,
diff --git a/services/trading_execution/fund_manager.py b/services/trading_execution/fund_manager.py
new file mode 100644
index 00000000..112a31d8
--- /dev/null
+++ b/services/trading_execution/fund_manager.py
@@ -0,0 +1,338 @@
+"""
+Fund Management Service
+Handles capital, ledger entries, and balance tracking for Paper and Live accounts.
+"""
+
+import logging
+from decimal import Decimal
+from typing import Dict, List, Any, Optional, Tuple
+from sqlalchemy.orm import Session
+from sqlalchemy import desc
+
+from database.models import User, PaperTradingAccount, BrokerConfig, FundLedger, AutoTradeExecution
+from utils.timezone_utils import get_ist_now_naive
+
+logger = logging.getLogger("fund_manager")
+
+class FundManagementService:
+ """
+ Manages all fund-related operations.
+ Acts as a central authority for balance updates and ledger entries.
+ """
+
+ def _add_ledger_entry(
+ self,
+ db: Session,
+ user_id: int,
+ trading_mode: str,
+ transaction_type: str,
+ category: str,
+ amount: Decimal,
+ balance_before: Decimal,
+ balance_after: Decimal,
+ description: str,
+ reference_id: Optional[str] = None
+ ) -> FundLedger:
+ """Helper to create a ledger entry"""
+ entry = FundLedger(
+ user_id=user_id,
+ trading_mode=trading_mode,
+ transaction_type=transaction_type,
+ category=category,
+ amount=amount,
+ balance_before=balance_before,
+ balance_after=balance_after,
+ description=description,
+ reference_id=reference_id,
+ timestamp=get_ist_now_naive()
+ )
+ db.add(entry)
+ return entry
+
+ def get_balances(self, user_id: int, db: Session, trading_mode: str = "paper") -> Dict[str, Any]:
+ """Get available, used, and total balances"""
+ if trading_mode == "paper":
+ account = db.query(PaperTradingAccount).filter(PaperTradingAccount.user_id == user_id).first()
+ if not account:
+ # Create default if not exists
+ account = PaperTradingAccount(
+ user_id=user_id,
+ initial_capital=100000.0,
+ current_balance=100000.0,
+ available_margin=100000.0,
+ used_margin=0.0
+ )
+ db.add(account)
+ db.commit()
+ db.refresh(account)
+
+ return {
+ "available_margin": float(account.available_margin),
+ "used_margin": float(account.used_margin),
+ "current_balance": float(account.current_balance),
+ "total_pnl": float(account.total_pnl),
+ "trading_mode": "paper"
+ }
+ else:
+ # Live Trading
+ broker_config = db.query(BrokerConfig).filter(
+ BrokerConfig.user_id == user_id,
+ BrokerConfig.is_active == True
+ ).first()
+
+ if not broker_config:
+ return {
+ "available_margin": 0.0,
+ "used_margin": 0.0,
+ "current_balance": 0.0,
+ "trading_mode": "live",
+ "error": "No active broker found"
+ }
+
+ available = broker_config.available_margin or 0.0
+ used = broker_config.used_margin or 0.0
+
+ return {
+ "available_margin": float(available),
+ "used_margin": float(used),
+ "current_balance": float(available + used),
+ "trading_mode": "live"
+ }
+
+ def add_paper_funds(self, user_id: int, amount: float, db: Session, description: str = "Funds added by user") -> Dict[str, Any]:
+ """Add virtual funds to paper trading account"""
+ try:
+ account = db.query(PaperTradingAccount).filter(PaperTradingAccount.user_id == user_id).first()
+ if not account:
+ # Create default
+ account = PaperTradingAccount(
+ user_id=user_id,
+ initial_capital=float(amount),
+ current_balance=float(amount),
+ available_margin=float(amount),
+ used_margin=0.0
+ )
+ db.add(account)
+ db.flush()
+ balance_before = Decimal('0.0')
+ else:
+ balance_before = Decimal(str(account.current_balance))
+ account.current_balance += float(amount)
+ account.available_margin += float(amount)
+ account.initial_capital += float(amount)
+
+ balance_after = Decimal(str(account.current_balance))
+
+ # Sync in-memory service
+ from services.paper_trading_account import paper_trading_service
+ mem_acc = paper_trading_service.accounts.get(user_id)
+ if mem_acc:
+ mem_acc.available_margin = float(account.available_margin)
+ mem_acc.current_balance = float(account.current_balance)
+ mem_acc.initial_capital = float(account.initial_capital)
+
+ # Ledger Entry
+ self._add_ledger_entry(
+ db=db,
+ user_id=user_id,
+ trading_mode="paper",
+ transaction_type="CREDIT",
+ category="FUND_ADDED",
+ amount=Decimal(str(amount)),
+ balance_before=balance_before,
+ balance_after=balance_after,
+ description=description
+ )
+
+ db.commit()
+ logger.info(f"✅ Added ₹{amount:,.2f} to paper account for user {user_id}")
+
+ return {
+ "success": True,
+ "amount": amount,
+ "new_balance": float(account.current_balance)
+ }
+ except Exception as e:
+ db.rollback()
+ logger.error(f"❌ Error adding paper funds: {e}")
+ return {"success": False, "error": str(e)}
+
+ def block_margin(self, user_id: int, amount: float, trading_mode: str, reference_id: str, db: Session) -> bool:
+ """Block margin for a new trade"""
+ try:
+ amt_decimal = Decimal(str(amount))
+ if trading_mode == "paper":
+ account = db.query(PaperTradingAccount).filter(PaperTradingAccount.user_id == user_id).first()
+ if not account or account.available_margin < float(amount):
+ return False
+
+ balance_before = Decimal(str(account.available_margin))
+ account.available_margin -= float(amount)
+ account.used_margin += float(amount)
+ balance_after = Decimal(str(account.available_margin))
+
+ # Sync in-memory service if it exists
+ from services.paper_trading_account import paper_trading_service
+ mem_acc = paper_trading_service.accounts.get(user_id)
+ if mem_acc:
+ mem_acc.available_margin = float(account.available_margin)
+ mem_acc.used_margin = float(account.used_margin)
+
+ # Ledger entry for margin blocking (DEBIT from available margin)
+ self._add_ledger_entry(
+ db=db,
+ user_id=user_id,
+ trading_mode="paper",
+ transaction_type="DEBIT",
+ category="TRADE_MARGIN_BLOCKED",
+ amount=amt_decimal,
+ balance_before=balance_before,
+ balance_after=balance_after,
+ description=f"Margin blocked for trade {reference_id}",
+ reference_id=reference_id
+ )
+ return True
+ else:
+ # For Live, we assume broker already blocked it, but we record it for statement
+ broker_config = db.query(BrokerConfig).filter(BrokerConfig.user_id == user_id, BrokerConfig.is_active == True).first()
+ if broker_config:
+ # Optional: locally update used_margin if we want to track it
+ pass
+
+ # Still add ledger entry for consistency in statement
+ # Balance for live is 'available_margin'
+ bal = Decimal(str(broker_config.available_margin or 0))
+ self._add_ledger_entry(
+ db=db,
+ user_id=user_id,
+ trading_mode="live",
+ transaction_type="DEBIT",
+ category="TRADE_MARGIN_BLOCKED",
+ amount=amt_decimal,
+ balance_before=bal,
+ balance_after=bal - amt_decimal, # Estimated local balance
+ description=f"Margin blocked for live trade {reference_id}",
+ reference_id=reference_id
+ )
+ return True
+ except Exception as e:
+ logger.error(f"Error blocking margin: {e}")
+ return False
+
+ def release_margin_and_settle(
+ self,
+ user_id: int,
+ trading_mode: str,
+ reference_id: str,
+ invested_amount: float,
+ gross_pnl: float,
+ brokerage: float,
+ taxes: float,
+ db: Session
+ ) -> bool:
+ """Release margin and settle P&L after trade exit"""
+ try:
+ net_pnl = gross_pnl - brokerage - taxes
+ invested_decimal = Decimal(str(invested_amount))
+ net_pnl_decimal = Decimal(str(net_pnl))
+ total_release = invested_decimal + net_pnl_decimal
+
+ if trading_mode == "paper":
+ account = db.query(PaperTradingAccount).filter(PaperTradingAccount.user_id == user_id).first()
+ if not account: return False
+
+ bal_before = Decimal(str(account.available_margin))
+
+ # Release used margin
+ account.used_margin = max(0.0, account.used_margin - invested_amount)
+
+ # Update balances
+ account.available_margin += float(total_release)
+ account.current_balance += float(net_pnl)
+ account.total_pnl += float(net_pnl)
+
+ # Sync in-memory service
+ from services.paper_trading_account import paper_trading_service
+ mem_acc = paper_trading_service.accounts.get(user_id)
+ if mem_acc:
+ mem_acc.available_margin = float(account.available_margin)
+ mem_acc.used_margin = float(account.used_margin)
+ mem_acc.current_balance = float(account.current_balance)
+ mem_acc.total_pnl = float(account.total_pnl)
+
+ bal_after = Decimal(str(account.available_margin))
+
+ # Ledger entry: Margin Released + P&L Settlement
+ self._add_ledger_entry(
+ db=db,
+ user_id=user_id,
+ trading_mode="paper",
+ transaction_type="CREDIT" if total_release >= 0 else "DEBIT",
+ category="PNL_SETTLEMENT",
+ amount=abs(total_release),
+ balance_before=bal_before,
+ balance_after=bal_after,
+ description=f"PnL Settlement for {reference_id} (Net: ₹{net_pnl:.2f})",
+ reference_id=reference_id
+ )
+
+ # Optional: Detail entries for Charges
+ if brokerage > 0 or taxes > 0:
+ self._add_ledger_entry(
+ db=db,
+ user_id=user_id,
+ trading_mode="paper",
+ transaction_type="DEBIT",
+ category="CHARGES",
+ amount=Decimal(str(brokerage + taxes)),
+ balance_before=bal_after + Decimal(str(brokerage + taxes)),
+ balance_after=bal_after,
+ description=f"Brokerage & Taxes for {reference_id}",
+ reference_id=reference_id
+ )
+
+ return True
+ else:
+ # Live settlement (Local record only)
+ broker_config = db.query(BrokerConfig).filter(BrokerConfig.user_id == user_id, BrokerConfig.is_active == True).first()
+ bal = Decimal(str(broker_config.available_margin or 0)) if broker_config else Decimal('0')
+
+ self._add_ledger_entry(
+ db=db,
+ user_id=user_id,
+ trading_mode="live",
+ transaction_type="CREDIT" if total_release >= 0 else "DEBIT",
+ category="PNL_SETTLEMENT",
+ amount=abs(total_release),
+ balance_before=bal,
+ balance_after=bal + net_pnl_decimal, # Estimated
+ description=f"Live PnL Settlement for {reference_id} (Net: ₹{net_pnl:.2f})",
+ reference_id=reference_id
+ )
+ return True
+ except Exception as e:
+ logger.error(f"Error releasing margin: {e}")
+ return False
+
+ def get_statement(self, user_id: int, db: Session, trading_mode: str = "paper", limit: int = 50) -> List[Dict[str, Any]]:
+ """Get fund statement (ledger entries)"""
+ entries = db.query(FundLedger).filter(
+ FundLedger.user_id == user_id,
+ FundLedger.trading_mode == trading_mode
+ ).order_by(desc(FundLedger.timestamp)).limit(limit).all()
+
+ return [
+ {
+ "id": e.id,
+ "timestamp": e.timestamp.isoformat(),
+ "type": e.transaction_type,
+ "category": e.category,
+ "amount": float(e.amount),
+ "balance_before": float(e.balance_before),
+ "balance_after": float(e.balance_after),
+ "description": e.description,
+ "reference_id": e.reference_id
+ } for e in entries
+ ]
+
+fund_manager = FundManagementService()
diff --git a/services/trading_execution/multi_demat_capital_service.py b/services/trading_execution/multi_demat_capital_service.py
index c256b26f..e98cc0ac 100644
--- a/services/trading_execution/multi_demat_capital_service.py
+++ b/services/trading_execution/multi_demat_capital_service.py
@@ -69,45 +69,15 @@ async def get_user_total_capital(
raise
async def _get_paper_trading_capital(self, user_id: int, db: Session) -> Dict[str, Any]:
- """Get paper trading virtual capital using the singleton service"""
- from services.paper_trading_account import paper_trading_service
+ """Get paper trading virtual capital using the fund manager service"""
+ from services.trading_execution.fund_manager import fund_manager
- # Get actual paper account state
- paper_account = await paper_trading_service.get_account(user_id, db)
+ # Get actual paper account state from fund manager
+ balances = fund_manager.get_balances(user_id, db, "paper")
- if paper_account:
- # Calculate totals based on PaperAccount state
- # total_available_capital (PaperAccount.current_balance) includes Cash + PnL
- # total_used_margin (PaperAccount.used_margin) is blocked capital
- # total_free_margin (PaperAccount.available_margin) is free cash for new trades
-
- # Note: In PaperAccount logic:
- # available_margin = initial - used + pnl
- # current_balance = initial - used (cash balance) -- Wait, let's verify PaperAccount logic
- # Logic from execute_trade:
- # used_margin += invested
- # available_margin -= invested
- # current_balance -= invested
- # Logic from close_position:
- # available_margin += invested + pnl
- # current_balance += invested + pnl
-
- # So:
- # available_margin = Free Cash (Available for trading)
- # current_balance = Free Cash (Same as available_margin in this implementation)
- # used_margin = Blocked Margin
- # Total Equity = available_margin + used_margin
-
- total_free_margin = Decimal(str(paper_account.available_margin))
- total_used_margin = Decimal(str(paper_account.used_margin))
- paper_capital = total_free_margin + total_used_margin
-
- else:
- # Fallback if account doesn't exist yet (create it implicitly)
- await paper_trading_service.create_paper_account(user_id)
- paper_capital = Decimal('100000')
- total_used_margin = Decimal('0')
- total_free_margin = paper_capital
+ total_free_margin = Decimal(str(balances.get("available_margin", 0)))
+ total_used_margin = Decimal(str(balances.get("used_margin", 0)))
+ paper_capital = Decimal(str(balances.get("current_balance", 0)))
return {
"user_id": user_id,
diff --git a/services/trading_execution/pnl_tracker.py b/services/trading_execution/pnl_tracker.py
index 4a24fcd3..f0505aab 100644
--- a/services/trading_execution/pnl_tracker.py
+++ b/services/trading_execution/pnl_tracker.py
@@ -967,59 +967,19 @@ def db_close_job(pos_id, trade_id, price, reason, order_id):
position.is_active = False
position.last_updated = get_ist_now_naive()
- # UPDATE PAPER TRADING BALANCE ON EXIT
- if trade_execution.trading_mode == "paper":
- try:
- from services.paper_trading_account import (
- paper_trading_service,
- )
- from database.models import PaperTradingAccount
-
- paper_account = (
- db.query(PaperTradingAccount)
- .filter(PaperTradingAccount.user_id == position.user_id)
- .first()
- )
- if paper_account:
- release_amount = float(sell_value - total_charges)
-
- paper_account.available_margin += release_amount
- paper_account.current_balance += release_amount
- paper_account.used_margin -= float(
- trade_execution.total_investment
- )
- paper_account.total_pnl += float(net_pnl)
- paper_account.daily_pnl += float(net_pnl)
- paper_account.positions_count = max(
- 0, paper_account.positions_count - 1
- )
- paper_account.updated_at = get_ist_now_naive()
-
- # Sync in-memory service
- mem_acc = paper_trading_service.accounts.get(
- position.user_id
- )
- if mem_acc:
- mem_acc.available_margin = float(
- paper_account.available_margin
- )
- mem_acc.current_balance = float(
- paper_account.current_balance
- )
- mem_acc.used_margin = float(
- paper_account.used_margin
- )
- mem_acc.total_pnl = float(paper_account.total_pnl)
- mem_acc.positions_count = (
- paper_account.positions_count
- )
-
- logger.info(
- f"✅ Paper account updated after exit: New Balance=₹{paper_account.current_balance:,.2f} (Returned ₹{release_amount:,.2f})"
- )
-
- except Exception as e:
- logger.error(f"Failed to update paper account on exit: {e}")
+ # SETTLE FUNDS & CREATE LEDGER ENTRIES (New Fund Management System)
+ from services.trading_execution.fund_manager import fund_manager
+
+ fund_manager.release_margin_and_settle(
+ user_id=position.user_id,
+ trading_mode=trade_execution.trading_mode,
+ reference_id=trade_execution.trade_id,
+ invested_amount=float(total_investment),
+ gross_pnl=float(gross_pnl),
+ brokerage=float(brokerage_flat),
+ taxes=float(taxes),
+ db=db
+ )
db.commit()
diff --git a/ui/trading-bot-ui/src/components/funds/AddFundsModal.js b/ui/trading-bot-ui/src/components/funds/AddFundsModal.js
new file mode 100644
index 00000000..51846d14
--- /dev/null
+++ b/ui/trading-bot-ui/src/components/funds/AddFundsModal.js
@@ -0,0 +1,159 @@
+import React, { useState } from "react";
+import { motion, AnimatePresence } from "framer-motion";
+import { X, Plus, ShieldCheck } from "lucide-react";
+import { toast } from "react-hot-toast";
+import api from "../../services/api";
+
+const AddFundsModal = ({ isOpen, onClose, onFundAdded }) => {
+ const [amount, setAmount] = useState("");
+ const [loading, setLoading] = useState(false);
+
+ const presetAmounts = [10000, 50000, 100000, 500000];
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ if (!amount || isNaN(amount) || amount <= 0) {
+ toast.error("Please enter a valid amount");
+ return;
+ }
+
+ setLoading(true);
+ try {
+ const response = await api.post(
+ `/v1/trading/execution/funds/add-paper-funds?amount=${amount}`,
+ );
+ if (response.data.success) {
+ toast.success(
+ `Successfully added ₹${parseFloat(amount).toLocaleString()} to paper account`,
+ );
+ onFundAdded(response.data.new_balance);
+ onClose();
+ setAmount("");
+ } else {
+ toast.error(response.data.error || "Failed to add funds");
+ }
+ } catch (error) {
+ console.error("Error adding funds:", error);
+ toast.error("An error occurred. Please try again.");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+ Increase your virtual capital
+
+ Add Paper Funds
+
+
Your fund movements will appear here.
+| Date & Description | +Type | +Amount | +Running Balance | +
+
+ {entry.description}
+
+
+
+ |
+
+
+ {entry.type === 'CREDIT' ? (
+
+
+
+ ) : (
+
+
+ )}
+
+ {entry.category.replace('_', ' ')}
+
+ |
+ + + {entry.type === 'CREDIT' ? '+' : '-'}{formatCurrency(entry.amount)} + + | ++ + {formatCurrency(entry.balance_after)} + + | +
|---|
Available Margin
+Used Margin
+Total Realized P&L
+Live Fund Sync
++ Live trading funds are managed by your connected broker. Balances shown here are fetched in real-time from your demat account. +
+ +{formatCurrency(pnlSummary.total_investment)}
Available Capital
+Available Capital
+ {tradingMode === 'paper' && ( + + )} +{formatCurrency(capitalData.total_free_margin)}
Used Margin
-{formatCurrency(capitalData.total_used_margin || 0)}
-{capitalData.capital_utilization_percent?.toFixed(1) || 0}% utilized
+{formatCurrency(Math.abs(capitalData.total_used_margin || 0))}
+{Math.abs(capitalData.capital_utilization_percent || 0).toFixed(1)}% utilized
Free Margin
+Available Cash
{formatCurrency(capitalData.total_free_margin || 0)}
{activeTab === "overview" && "Comprehensive view of your trading performance and account status."} {activeTab === "performance" && "Detailed analytics, P&L reports, and trade history."} + {activeTab === "funds" && "Manage your trading capital, add funds, and view transaction logs."} {activeTab === "brokers" && "Manage your connected brokerage accounts and API keys."} {activeTab === "settings" && "Update your personal information and preferences."} {activeTab === "security" && "Manage password, 2FA, and security logs."}