From 5836c161180467a9f444be9e0aafeab134517767 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Sat, 27 Dec 2025 04:12:50 +0000 Subject: [PATCH] Auto commit: 2025-12-27T04:12:50.711Z --- .gitignore | 5 + algo_trader/README.md | 46 ++++++ algo_trader/app.py | 169 +++++++++++++++++++++ algo_trader/config/credentials.env.example | 4 + algo_trader/config/settings.yaml | 21 +++ algo_trader/core/broker.py | 125 +++++++++++++++ algo_trader/core/data_feed.py | 90 +++++++++++ algo_trader/core/order_manager.py | 156 +++++++++++++++++++ algo_trader/core/portfolio.py | 1 + algo_trader/core/risk_manager.py | 144 ++++++++++++++++++ algo_trader/core/strategy_engine.py | 82 ++++++++++ algo_trader/requirements.txt | 12 ++ algo_trader/storage/database.py | 37 +++++ algo_trader/storage/models.py | 65 ++++++++ algo_trader/strategies/equity_swing.py | 1 + algo_trader/strategies/intraday_fno.py | 1 + algo_trader/ui/dashboard.py | 50 ++++++ algo_trader/ui/logs_panel.py | 26 ++++ algo_trader/ui/main_window.py | 90 +++++++++++ algo_trader/ui/orders_panel.py | 37 +++++ algo_trader/ui/positions_panel.py | 46 ++++++ algo_trader/ui/strategy_panel.py | 45 ++++++ algo_trader/utils/helpers.py | 1 + algo_trader/utils/logger.py | 1 + 24 files changed, 1255 insertions(+) create mode 100644 algo_trader/README.md create mode 100644 algo_trader/app.py create mode 100644 algo_trader/config/credentials.env.example create mode 100644 algo_trader/config/settings.yaml create mode 100644 algo_trader/core/broker.py create mode 100644 algo_trader/core/data_feed.py create mode 100644 algo_trader/core/order_manager.py create mode 100644 algo_trader/core/portfolio.py create mode 100644 algo_trader/core/risk_manager.py create mode 100644 algo_trader/core/strategy_engine.py create mode 100644 algo_trader/requirements.txt create mode 100644 algo_trader/storage/database.py create mode 100644 algo_trader/storage/models.py create mode 100644 algo_trader/strategies/equity_swing.py create mode 100644 algo_trader/strategies/intraday_fno.py create mode 100644 algo_trader/ui/dashboard.py create mode 100644 algo_trader/ui/logs_panel.py create mode 100644 algo_trader/ui/main_window.py create mode 100644 algo_trader/ui/orders_panel.py create mode 100644 algo_trader/ui/positions_panel.py create mode 100644 algo_trader/ui/strategy_panel.py create mode 100644 algo_trader/utils/helpers.py create mode 100644 algo_trader/utils/logger.py diff --git a/.gitignore b/.gitignore index e427ff3..a94bcb4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ node_modules/ */node_modules/ */build/ + +# Ignore environment files and access tokens +*.env +access_token.txt +algo_trader/storage/trader.db diff --git a/algo_trader/README.md b/algo_trader/README.md new file mode 100644 index 0000000..22ac7d5 --- /dev/null +++ b/algo_trader/README.md @@ -0,0 +1,46 @@ +# Professional-Grade Trading Application + +## Overview + +This is a private, professional-grade desktop trading application for personal use. It is built with Python and PySide6 for the UI. + +## Features + +- Intraday F&O trading (Index + Stocks) +- Swing / compounding equity trading +- Strict risk management +- Strategy-based execution +- Real-time monitoring +- Manual override & safety controls + +## Tech Stack + +- **Language:** Python 3.10+ +- **UI Framework:** PySide6 (Qt) +- **Charts:** pyqtgraph +- **Database:** SQLite +- **Async:** asyncio + threading +- **Broker:** FYERS API (v3) + +## Setup + +1. **Install dependencies:** + ```bash + pip install -r requirements.txt + ``` + +2. **Configure credentials:** + Copy `config/credentials.env.example` to `config/credentials.env` and fill in your FYERS API credentials. + +3. **Run the application:** + ```bash + python algo_trader/app.py + ``` + +## ⚠️ Warnings ⚠️ + +- **This is not a toy.** This is a trading application that can execute real trades with real money. +- **Use at your own risk.** The author is not responsible for any financial losses. +- **Paper trade first.** Always test your strategies in a paper trading environment before going live. +- **No auto-start.** The application will not start automatically. You must manually start it. +- **No unattended trading.** Do not leave the application running unattended. diff --git a/algo_trader/app.py b/algo_trader/app.py new file mode 100644 index 0000000..1bd213d --- /dev/null +++ b/algo_trader/app.py @@ -0,0 +1,169 @@ +import sys +import asyncio +import yaml +from PySide6.QtWidgets import QApplication +from loguru import logger +from sqlalchemy.orm import sessionmaker + +from ui.main_window import MainWindow +from core.broker import FyersBroker +from core.data_feed import FyersDataFeed +from storage.database import get_engine, init_db +from core.order_manager import OrderManager +from core.strategy_engine import StrategyEngine +from core.risk_manager import RiskManager + + +async def on_connect(): + """Callback on WebSocket connect.""" + logger.success("Data feed connected.") + + +async def on_close(): + """Callback on WebSocket close.""" + logger.warning("Data feed connection closed.") + + +async def on_error(error_msg): + """Callback for WebSocket errors.""" + logger.error(f"Data feed error: {error_msg}") + + +def setup_logging(settings: dict): + """Configures the logger based on settings.""" + logger.add( + settings["logging"]["file"], + level=settings["logging"]["level"], + rotation="10 MB", + ) + + +async def run_backend( + broker, + data_feed, + db_session, + order_manager, + risk_manager, + strategy_engine, + settings, +): + try: + # --- Broker and Core Components Initialization --- + access_token = await broker.get_access_token() + + if access_token and broker.fyers: + profile = await broker.get_profile() + logger.info(f"Broker Profile: {profile}") + + # Now set the order_manager in risk_manager + risk_manager.order_manager = order_manager + + # --- Connect and Run --- + ws_access_token = f"{broker.client_id}:{access_token}" + await data_feed.connect(ws_access_token) + + # Load strategy and subscribe to symbols required by it + strategy_engine.load_strategy("simple_crossover") + await data_feed.subscribe(["NSE:SBIN-EQ"]) + + logger.info("Auto-trading strategy is now running. Waiting for signals...") + # Keep the application running to listen for ticks + # The UI event loop will keep the application alive + # asyncio.sleep(30) is removed as UI will manage lifetime + else: + logger.error( + "Broker or token initialization failed. Check credentials and logs." + ) + + except Exception as e: + logger.opt(exception=True).critical( + f"An error occurred during backend runtime: {e}" + ) + finally: + # Cleanup should be handled by the UI's close event or a dedicated shutdown + logger.info("Backend run function finished.") + + +async def main(): + """Main function to run the application.""" + try: + with open("config/settings.yaml", "r") as f: + settings = yaml.safe_load(f) + except FileNotFoundError: + print("Error: settings.yaml not found. Please ensure the file exists.") + sys.exit(1) + except yaml.YAMLError as e: + print(f"Error parsing settings.yaml: {e}") + sys.exit(1) + + setup_logging(settings) + logger.info(f"Starting {settings['app']['name']} v{settings['app']['version']}") + + # --- Database Initialization --- + db_session = None + try: + db_path = settings["database"]["path"] + engine = get_engine(db_path) + init_db(engine) + Session = sessionmaker(bind=engine) + db_session = Session() + logger.success("Database initialized successfully.") + except Exception as e: + logger.opt(exception=True).critical( + f"An error occurred during database initialization: {e}" + ) + sys.exit(1) + + broker = None + data_feed = None + order_manager = None + risk_manager = None + strategy_engine = None + + try: + broker = FyersBroker(settings=settings["fyers"]) + risk_manager = RiskManager(order_manager=None, settings=settings, logger=logger) + order_manager = OrderManager( + broker=broker, db_session=db_session, risk_manager=risk_manager + ) + strategy_engine = StrategyEngine(order_manager=order_manager) + data_feed = FyersDataFeed( + fyers=broker.fyers, + on_connect=on_connect, + on_tick=strategy_engine.on_tick, + on_close=on_close, + on_error=on_error, + ) + + # Launch UI and pass backend components + app = QApplication(sys.argv) + main_window = MainWindow( + broker=broker, + data_feed=data_feed, + db_session=db_session, + order_manager=order_manager, + risk_manager=risk_manager, + strategy_engine=strategy_engine, + settings=settings, + backend_runner=run_backend # Pass the async backend runner + ) + main_window.show() + logger.info("Application UI started.") + + # Run the Qt event loop in a separate thread or integrate with asyncio + # For simplicity, we'll let QApplication manage the main thread + # and run the backend in the asyncio event loop managed by a QThread or similar + # For now, simply run the Qt app. The backend needs to be started + # from within the UI or via a separate asyncio loop. + sys.exit(app.exec()) + + except Exception as e: + logger.opt(exception=True).critical(f"An error occurred: {e}") + finally: + if db_session: + db_session.close() + logger.info("Application shutdown complete.") + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/algo_trader/config/credentials.env.example b/algo_trader/config/credentials.env.example new file mode 100644 index 0000000..aea45f1 --- /dev/null +++ b/algo_trader/config/credentials.env.example @@ -0,0 +1,4 @@ +# FYERS API Credentials +FYERS_APP_ID="" +FYERS_SECRET_KEY="" +FYERS_REDIRECT_URI="" diff --git a/algo_trader/config/settings.yaml b/algo_trader/config/settings.yaml new file mode 100644 index 0000000..2f54f0b --- /dev/null +++ b/algo_trader/config/settings.yaml @@ -0,0 +1,21 @@ +app: + name: "AlgoTrader" + version: "0.1.0" + +fyers: + credentials_path: "config/credentials.env" + access_token_path: "access_token.txt" + +logging: + level: "INFO" + file: "logs/app.log" + +database: + path: "storage/trader.db" + +risk: + max_daily_loss_percentage: 0.01 + default_position_size: 10 + stop_loss_percentage: 0.005 + take_profit_percentage: 0.01 + risk_per_trade_percentage: 0.005 \ No newline at end of file diff --git a/algo_trader/core/broker.py b/algo_trader/core/broker.py new file mode 100644 index 0000000..3d6a65d --- /dev/null +++ b/algo_trader/core/broker.py @@ -0,0 +1,125 @@ +import os +import webbrowser +from dotenv import load_dotenv +from fyers_api import fyersModel, accessToken +from loguru import logger + +class FyersBroker: + """ + Handles all interactions with the Fyers API. + """ + def __init__(self, settings: dict): + """ + Initializes the FyersBroker. + + Args: + settings (dict): Application settings containing API credentials. + """ + load_dotenv(dotenv_path=settings['credentials_path']) + self.client_id = os.getenv('FYERS_CLIENT_ID') + self.secret_key = os.getenv('FYERS_SECRET_KEY') + self.redirect_uri = os.getenv('FYERS_REDIRECT_URI') + self.access_token_path = settings['access_token_path'] + + self.fyers = None + self.access_token = self._load_access_token() + + if not self.access_token: + self._generate_new_access_token() + + self._initialize_fyers_model() + + def _load_access_token(self) -> str | None: + """Loads access token from the specified file.""" + try: + if os.path.exists(self.access_token_path): + with open(self.access_token_path, 'r') as f: + return f.read().strip() + return None + except Exception as e: + logger.error(f"Error loading access token: {e}") + return None + + def _save_access_token(self, token: str): + """Saves access token to the specified file.""" + try: + with open(self.access_token_path, 'w') as f: + f.write(token) + logger.info("Access token saved successfully.") + except Exception as e: + logger.error(f"Error saving access token: {e}") + + def _generate_new_access_token(self): + """Generates a new access token using the auth code flow.""" + session = accessToken.SessionModel( + client_id=self.client_id, + secret_key=self.secret_key, + redirect_uri=self.redirect_uri, + response_type='code', + grant_type='authorization_code' + ) + + try: + response = session.generate_authcode() + logger.info(f"Auth code generation response: {response}") + print(f"Login URL: {response}") + webbrowser.open(response) + + auth_code = input("Enter the auth code from the redirected URL: ") + session.set_token(auth_code) + + access_token_response = session.generate_token() + self.access_token = access_token_response['access_token'] + self._save_access_token(self.access_token) + logger.info("New access token generated and saved.") + + except Exception as e: + logger.error(f"Error generating new access token: {e}") + self.access_token = None + + def _initialize_fyers_model(self): + """Initializes the FyersModel with the access token.""" + if self.access_token: + try: + self.fyers = fyersModel.FyersModel( + client_id=self.client_id, + is_async=True, + token=self.access_token, + log_path=os.path.join(os.path.dirname(__file__), '..' , 'logs') + ) + logger.info("FyersModel initialized successfully.") + except Exception as e: + logger.error(f"Error initializing FyersModel: {e}") + self.fyers = None + else: + logger.warning("FyersModel could not be initialized. Access token is missing.") + + async def get_profile(self): + """Fetches user profile details.""" + if not self.fyers: + return {"error": "FyersModel not initialized."} + try: + response = await self.fyers.get_profile() + if response['s'] == 'ok': + return response['data'] + else: + logger.error(f"Error fetching profile: {response['message']}") + return {"error": response['message']} + except Exception as e: + logger.error(f"Exception fetching profile: {e}") + return {"error": str(e)} + + async def get_funds(self): + """Fetches user funds details.""" + if not self.fyers: + return {"error": "FyersModel not initialized."} + try: + response = await self.fyers.get_funds() + if response['s'] == 'ok': + return response['fund_limit'] + else: + logger.error(f"Error fetching funds: {response['message']}") + return {"error": response['message']} + except Exception as e: + logger.error(f"Exception fetching funds: {e}") + return {"error": str(e)} \ No newline at end of file diff --git a/algo_trader/core/data_feed.py b/algo_trader/core/data_feed.py new file mode 100644 index 0000000..24c0d2a --- /dev/null +++ b/algo_trader/core/data_feed.py @@ -0,0 +1,90 @@ +"""Core: WebSocket market data feed.""" + +import asyncio +from typing import Awaitable, Callable + +from fyers_api_sdk.fyers_async import FyersAsync +from loguru import logger + + +class FyersDataFeed: + """Handles WebSocket connection for real-time data.""" + + def __init__( + self, + fyers: FyersAsync, + on_connect: Callable[[], Awaitable[None]], + on_tick: Callable[[dict], Awaitable[None]], + on_close: Callable[[], Awaitable[None]] | None = None, + on_error: Callable[[str], Awaitable[None]] | None = None, + ): + """ + Initializes the FyersDataFeed. + Args: + fyers: An authenticated FyersAsync instance. + on_connect: Async callback to run on successful connection. + on_tick: Async callback to process each incoming tick. + on_close: Async callback for when the connection closes. + on_error: Async callback to handle errors. + """ + self._fyers = fyers + self._on_connect = on_connect + self._on_tick = on_tick + self._on_close = on_close + self._on_error = on_error + self.websocket = None + + async def connect(self, access_token: str, data_type: str = "symbolData"): + """Establishes a WebSocket connection.""" + logger.info("Connecting to Fyers WebSocket...") + try: + self.websocket = self._fyers.fyers_market_socket( + access_token=access_token, + log_path="", # Disable log file generation + on_connect=lambda: asyncio.create_task(self._on_connect()), + on_close=lambda: asyncio.create_task(self._handle_close()), + on_error=lambda msg: asyncio.create_task(self._handle_error(msg)), + on_message=lambda msg: asyncio.create_task(self._on_tick(msg)), + ) + await asyncio.to_thread(self.websocket.connect) + logger.success("Fyers WebSocket connected.") + except Exception as e: + logger.error(f"Failed to connect to WebSocket: {e}") + if self._on_error: + await self._on_error(str(e)) + + async def subscribe(self, symbols: list[str]): + """Subscribes to a list of symbols.""" + if self.websocket: + logger.info(f"Subscribing to symbols: {symbols}") + request = {"T": "SUB_L2", "L2_T": "T", "V": symbols} + self.websocket.send_message(request) + else: + logger.warning("WebSocket not connected. Cannot subscribe.") + + async def unsubscribe(self, symbols: list[str]): + """Unsubscribes from a list of symbols.""" + if self.websocket: + logger.info(f"Unsubscribing from symbols: {symbols}") + request = {"T": "UNSUB_L2", "V": symbols} + self.websocket.send_message(request) + else: + logger.warning("WebSocket not connected. Cannot unsubscribe.") + + async def close(self): + """Closes the WebSocket connection.""" + if self.websocket: + logger.info("Closing WebSocket connection.") + self.websocket.close() + + async def _handle_close(self): + """Internal handler for close events.""" + logger.info("WebSocket connection closed.") + if self._on_close: + await self._on_close() + + async def _handle_error(self, message: str): + """Internal handler for error events.""" + logger.error(f"WebSocket error: {message}") + if self._on_error: + await self._on_error(message) \ No newline at end of file diff --git a/algo_trader/core/order_manager.py b/algo_trader/core/order_manager.py new file mode 100644 index 0000000..954e993 --- /dev/null +++ b/algo_trader/core/order_manager.py @@ -0,0 +1,156 @@ +"""Core: Order execution and management.""" + +import asyncio +from datetime import datetime +from typing import Dict, Any +from loguru import logger + +from storage.models import Order +from .risk_manager import RiskManager + + +class OrderManager: + """Handles order creation, tracking, and lifecycle management.""" + + def __init__(self, broker, db_session, risk_manager: RiskManager): + """ + Initializes the OrderManager. + + Args: + broker: The broker instance to execute trades. + db_session: The database session for persistence. + risk_manager: The RiskManager instance for risk validation. + """ + self.broker = broker + self.db_session = db_session + self.risk_manager = risk_manager + self.orders = {} # In-memory cache for active orders + + async def place_order( + self, + symbol: str, + side: str, + current_price: float, + quantity: int, + order_type: str, + limit_price: float = None, + stop_price: float = None, + is_async: bool = True, + ) -> Dict[str, Any]: + """ + Places a new order with the broker and stores it in the database. + + Args: + symbol (str): The symbol to trade (e.g., 'NSE:SBIN-EQ'). + side (str): 'BUY' or 'SELL'. + quantity (int): The number of shares. + order_type (str): 'LIMIT', 'MARKET', 'STOP', etc. + limit_price (float, optional): The limit price for LIMIT orders. + stop_price (float, optional): The stop price for STOP orders. + is_async (bool, optional): FYERS specific flag. Defaults to True. + + Returns: + dict: The response from the broker. + """ + logger.info( + f"Placing {side} order for {quantity} {symbol} at {order_type}." + ) + + order_data_for_validation = { + "symbol": symbol, + "side": side, + "quantity": quantity, + "order_type": order_type, + "limit_price": limit_price, + "stop_price": stop_price, + "current_price": current_price, # Add current price for risk manager + } + + if not self.risk_manager.validate_order(order_data_for_validation): + return {"error": "Order rejected by risk manager"} + + # Use adjusted quantity, stop_loss_price, take_profit_price from risk manager + adjusted_quantity = order_data_for_validation["quantity"] + adjusted_stop_loss_price = order_data_for_validation.get("stop_loss_price") + adjusted_take_profit_price = order_data_for_validation.get("take_profit_price") + + try: + order_data = { + "symbol": symbol, + "qty": adjusted_quantity, + "type": self._map_order_type(order_type), + "side": self._map_side(side), + "productType": "INTRADAY", # Or CNC, MARGIN, etc. + "limitPrice": adjusted_take_profit_price if adjusted_take_profit_price else 0, # Use take profit as limit for now + "stopPrice": adjusted_stop_loss_price if adjusted_stop_loss_price else 0, + "validity": "DAY", + "disclosedQty": 0, + "offlineOrder": "False", + } + + # The fyers_sdk places orders synchronously, but we run it in an executor + loop = asyncio.get_running_loop() + broker_response = await loop.run_in_executor( + None, self.broker.place_order, order_data + ) + + if broker_response and broker_response.get("s") == "ok": + order_id = broker_response.get("id") + logger.success(f"Order placed successfully. Broker ID: {order_id}") + await self._store_order( + order_id, symbol, side, quantity, order_type, limit_price + ) + return broker_response + else: + error_msg = broker_response.get( + "message", "Unknown error from broker" + ) + logger.error(f"Failed to place order: {error_msg}") + return {"error": error_msg} + + except Exception as e: + logger.error(f"Exception placing order: {e}") + return {"error": str(e)} + + async def _store_order( + self, order_id, symbol, side, quantity, order_type, price + ): + """Stores the order details in the database.""" + try: + new_order = Order( + broker_order_id=order_id, + symbol=symbol, + side=side, + quantity=quantity, + order_type=order_type, + price=price, + status="PENDING", # Initial status + timestamp=datetime.utcnow(), + ) + self.db_session.add(new_order) + self.db_session.commit() + self.orders[order_id] = new_order # Cache it + logger.info(f"Order {order_id} stored in database.") + except Exception as e: + logger.error(f"Failed to store order {order_id}: {e}") + self.db_session.rollback() + + def _map_order_type(self, order_type: str) -> int: + """Maps internal order types to FYERS API integer codes.""" + mapping = {"LIMIT": 1, "MARKET": 2, "STOP": 3, "STOP_LIMIT": 4} + return mapping.get(order_type.upper(), 2) # Default to MARKET + + def _map_side(self, side: str) -> int: + """Maps internal side to FYERS API integer codes.""" + mapping = {"BUY": 1, "SELL": -1} + return mapping.get(side.upper(), 1) # Default to BUY + + async def get_order_status(self, order_id: str): + """Retrieves the status of an order from the broker.""" + # Placeholder for fetching order status from broker + pass + + async def cancel_order(self, order_id: str): + """Cancels an active order.""" + # Placeholder for cancelling an order + pass \ No newline at end of file diff --git a/algo_trader/core/portfolio.py b/algo_trader/core/portfolio.py new file mode 100644 index 0000000..1c40b4e --- /dev/null +++ b/algo_trader/core/portfolio.py @@ -0,0 +1 @@ +"""Core: PnL and holdings tracking.""" diff --git a/algo_trader/core/risk_manager.py b/algo_trader/core/risk_manager.py new file mode 100644 index 0000000..88638a2 --- /dev/null +++ b/algo_trader/core/risk_manager.py @@ -0,0 +1,144 @@ +"""Core: Risk management and capital protection.""" + +from typing import Dict, Any +from .order_manager import OrderManager +from ..storage.models import Order +from decimal import Decimal + +class RiskManager: + def __init__(self, order_manager: OrderManager, settings: Dict[str, Any], logger: Any): + self.order_manager = order_manager + self.settings = settings.get("risk", {}) + self.logger = logger + self.max_daily_loss_percentage = Decimal(str(self.settings.get("max_daily_loss_percentage", 0.01))) + self.default_position_size = self.settings.get("default_position_size", 10) + self.stop_loss_percentage = Decimal(str(self.settings.get("stop_loss_percentage", 0.005))) + self.take_profit_percentage = Decimal(str(self.settings.get("take_profit_percentage", 0.01))) + self.risk_per_trade_percentage = Decimal(str(self.settings.get("risk_per_trade_percentage", 0.005))) + self.daily_profit_loss_today = Decimal('0.0') # Track daily P&L for current day + + self.logger.info("RiskManager initialized with settings: %s", self.settings) + self._reset_daily_metrics() + + def _reset_daily_metrics(self): + """ + Resets daily metrics like profit/loss. + In a real application, this would be triggered at the start of a new trading day. + """ + self.daily_profit_loss_today = Decimal('0.0') + self.logger.info("Daily risk metrics reset.") + + def validate_order(self, order_data: Dict[str, Any]) -> bool: + """ + Validates if an order can be placed based on risk rules and adjusts order parameters. + """ + if not self._monitor_daily_loss(): + self.logger.warning("Order rejected due to exceeding maximum daily loss.") + return False + + symbol = order_data.get("symbol") + side = order_data.get("side") + current_price = Decimal(str(order_data.get("current_price"))) + + # Create a dummy Order object for _calculate_stop_loss_take_profit + # In a real system, the order would be more fully formed here or SL/TP calculation + # would be refactored to take individual parameters. + dummy_order = Order(symbol=symbol, side=side, quantity=0, price=current_price) # Only side and symbol are needed for SL/TP calcs + sl_tp_prices = self._calculate_stop_loss_take_profit(dummy_order, current_price) + stop_loss_price = sl_tp_prices["stop_loss"] + take_profit_price = sl_tp_prices["take_profit"] + + quantity = self._calculate_position_size(symbol, current_price, stop_loss_price) + if quantity == 0: + self.logger.warning("Order rejected due to calculated position size being zero.") + return False + + # Update order_data with calculated values + order_data["quantity"] = quantity + order_data["stop_loss_price"] = stop_loss_price + order_data["take_profit_price"] = take_profit_price + + self.logger.info("Order validated and adjusted by RiskManager: %s", order_data) + return True + + def _get_current_portfolio_value(self) -> Decimal: + """ + Placeholder to fetch the current total portfolio value. + In a real system, this would interact with the portfolio manager or broker. + """ + # For now, return a dummy value + return Decimal('100000.00') + + def _calculate_position_size(self, symbol: str, current_price: Decimal, stop_loss_price: Decimal) -> int: + """ + Calculates the appropriate position size based on risk settings, portfolio, and stop-loss price. + """ + if current_price <= Decimal('0.0') or stop_loss_price <= Decimal('0.0') or current_price == stop_loss_price: + self.logger.warning( + "Cannot calculate position size with invalid prices: current_price=%s, stop_loss_price=%s", + current_price, stop_loss_price + ) + return 0 + + portfolio_value = self._get_current_portfolio_value() + # Risk a small percentage of portfolio per trade + risk_per_trade_capital = portfolio_value * self.settings.get("risk_per_trade_percentage", Decimal('0.005')) # e.g., 0.5% + + # Calculate the potential loss per share if stop-loss is hit + loss_per_share = abs(current_price - stop_loss_price) + + if loss_per_share == Decimal('0.0'): + self.logger.warning("Loss per share is zero, cannot calculate position size safely. Defaulting to 1.") + return 1 + + # Calculate quantity + quantity = int(risk_per_trade_capital / loss_per_share) + + if quantity == 0: + quantity = 1 # Ensure at least 1 share is traded if valid + + self.logger.info( + "Calculated position size for %s: %s (Current Price: %s, Stop Loss: %s, Portfolio Value: %s, Risk per Trade Capital: %s)", + symbol, quantity, current_price, stop_loss_price, portfolio_value, risk_per_trade_capital + ) + return min(quantity, self.default_position_size) # Cap at default for now + + def _calculate_stop_loss_take_profit(self, order: Order, current_price: Decimal) -> Dict[str, Decimal]: + """ + Calculates stop loss and take profit prices for an order. + """ + stop_loss_price = Decimal('0.0') + take_profit_price = Decimal('0.0') + + if order.side == "BUY": + stop_loss_price = current_price * (Decimal('1.0') - self.stop_loss_percentage) + take_profit_price = current_price * (Decimal('1.0') + self.take_profit_percentage) + elif order.side == "SELL": + stop_loss_price = current_price * (Decimal('1.0') + self.stop_loss_percentage) + take_profit_price = current_price * (Decimal('1.0') - self.take_profit_percentage) + + self.logger.info( + "Calculated SL/TP for %s at price %s: SL=%s, TP=%s", + order.symbol, current_price, stop_loss_price, take_profit_price + ) + return {"stop_loss": stop_loss_price, "take_profit": take_profit_price} + + def _monitor_daily_loss(self) -> bool: + """ + Monitors the daily profit/loss and returns False if the max daily loss is exceeded. + """ + portfolio_value = self._get_current_portfolio_value() + if portfolio_value > 0 and self.daily_profit_loss_today / portfolio_value < -self.max_daily_loss_percentage: + self.logger.warning( + "Maximum daily loss exceeded. Current P&L: %s, Max Loss: %s", + self.daily_profit_loss_today, -self.max_daily_loss_percentage * portfolio_value + ) + return False + return True + + def update_daily_profit_loss(self, pnl: Decimal): + """ + Updates the daily profit and loss. + """ + self.daily_profit_loss_today += pnl + self.logger.info("Daily P&L updated: %s", self.daily_profit_loss_today) \ No newline at end of file diff --git a/algo_trader/core/strategy_engine.py b/algo_trader/core/strategy_engine.py new file mode 100644 index 0000000..ea47e02 --- /dev/null +++ b/algo_trader/core/strategy_engine.py @@ -0,0 +1,82 @@ +"""Core: Strategy orchestration and signal generation.""" + +from loguru import logger + + +class StrategyEngine: + """Hosts and executes trading strategies, generating signals from market data.""" + + def __init__(self, order_manager): + """ + Initializes the StrategyEngine. + + Args: + order_manager: The OrderManager instance to send signals to. + """ + self.order_manager = order_manager + self.strategy = None + self.last_price = None # For our simple demo strategy + + def load_strategy(self, strategy_name: str): + """ + Loads a trading strategy. For now, we use a simple hardcoded one. + In the future, this will dynamically load strategy modules. + """ + if strategy_name == "simple_crossover": + self.strategy = self._simple_crossover_strategy + logger.info(f"Loaded strategy: {strategy_name}") + else: + logger.error(f"Strategy '{strategy_name}' not found.") + + async def on_tick(self, tick: dict): + """ + Called by the DataFeed on every new market data tick. + Executes the loaded strategy. + + Args: + tick (dict): The tick data from the data feed. + """ + if not self.strategy: + return + + # The tick format from FYERS WebSocket is a list of dicts + # For simplicity, we assume we get one instrument's data at a time + if isinstance(tick, list) and tick: + tick = tick[0] + + ltp = tick.get("ltp") + symbol = tick.get("symbol") + + if ltp and symbol: + await self.strategy(symbol, ltp) + + async def _simple_crossover_strategy(self, symbol: str, current_price: float): + """ + A very basic placeholder strategy. + - If price goes up, BUY. + - If price goes down, SELL. + - Acts only on the first price change. + + This is for demonstration and is NOT a profitable strategy. + """ + if self.last_price is None: + self.last_price = current_price + logger.info(f"Starting price for {symbol} is {current_price}. Waiting for change.") + return + + # Only trade once for this demo + if abs(self.last_price - current_price) > 0.1: # Threshold to act + if current_price > self.last_price: + logger.warning(f"Price increased: {self.last_price} -> {current_price}. Sending BUY signal.") + await self.order_manager.place_order( + symbol=symbol, side="BUY", current_price=current_price, quantity=1, order_type="MARKET" + ) + elif current_price < self.last_price: + logger.warning(f"Price decreased: {self.last_price} -> {current_price}. Sending SELL signal.") + await self.order_manager.place_order( + symbol=symbol, side="SELL", current_price=current_price, quantity=1, order_type="MARKET" + ) + + # Stop further trading in this simple example + self.strategy = None + logger.info("Demo trade placed. Strategy deactivated.") \ No newline at end of file diff --git a/algo_trader/requirements.txt b/algo_trader/requirements.txt new file mode 100644 index 0000000..8c080bd --- /dev/null +++ b/algo_trader/requirements.txt @@ -0,0 +1,12 @@ +PySide6 +pyqtgraph +pandas +numpy +ta +fyers-apiv3 +python-dotenv +loguru +websocket-client +PyYAML +SQLAlchemy +alembic \ No newline at end of file diff --git a/algo_trader/storage/database.py b/algo_trader/storage/database.py new file mode 100644 index 0000000..db8942c --- /dev/null +++ b/algo_trader/storage/database.py @@ -0,0 +1,37 @@ +"""Storage: SQLite database handler.""" + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from .models import Base + +def get_engine(db_path: str): + """Creates a SQLAlchemy engine. + + Args: + db_path: Path to the SQLite database file. + + Returns: + A SQLAlchemy engine instance. + """ + return create_engine(f"sqlite:///{db_path}") + +def get_session(engine): + """Creates a session factory and returns a session. + + Args: + engine: A SQLAlchemy engine instance. + + Returns: + A SQLAlchemy session. + """ + Session = sessionmaker(bind=engine) + return Session() + +def init_db(engine): + """Creates all tables in the database. + + Args: + engine: A SQLAlchemy engine instance. + """ + Base.metadata.create_all(engine) \ No newline at end of file diff --git a/algo_trader/storage/models.py b/algo_trader/storage/models.py new file mode 100644 index 0000000..2597093 --- /dev/null +++ b/algo_trader/storage/models.py @@ -0,0 +1,65 @@ +"""Storage: Data models for trades, orders, etc.""" + +from sqlalchemy import create_engine, Column, Integer, String, Float, DateTime, Enum +from sqlalchemy.orm import declarative_base +from sqlalchemy.sql import func +import enum + +Base = declarative_base() + +class OrderStatus(enum.Enum): + PENDING = "PENDING" + OPEN = "OPEN" + EXECUTED = "EXECUTED" + CANCELED = "CANCELED" + REJECTED = "REJECTED" + +class OrderType(enum.Enum): + MARKET = "MARKET" + LIMIT = "LIMIT" + +class TransactionType(enum.Enum): + BUY = "BUY" + SELL = "SELL" + +class Order(Base): + __tablename__ = 'orders' + + id = Column(Integer, primary_key=True) + symbol = Column(String, nullable=False) + quantity = Column(Integer, nullable=False) + price = Column(Float) + order_type = Column(Enum(OrderType), nullable=False) + transaction_type = Column(Enum(TransactionType), nullable=False) + status = Column(Enum(OrderStatus), default=OrderStatus.PENDING) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + def __repr__(self): + return f"" + +class Trade(Base): + __tablename__ = 'trades' + + id = Column(Integer, primary_key=True) + order_id = Column(Integer, nullable=False) # FK to Order table + symbol = Column(String, nullable=False) + quantity = Column(Integer, nullable=False) + price = Column(Float, nullable=False) + transaction_type = Column(Enum(TransactionType), nullable=False) + trade_time = Column(DateTime, default=func.now()) + + def __repr__(self): + return f"" + +class Position(Base): + __tablename__ = 'positions' + + id = Column(Integer, primary_key=True) + symbol = Column(String, unique=True, nullable=False) + quantity = Column(Integer, nullable=False) + average_price = Column(Float, nullable=False) + last_updated = Column(DateTime, default=func.now(), onupdate=func.now()) + + def __repr__(self): + return f"" \ No newline at end of file diff --git a/algo_trader/strategies/equity_swing.py b/algo_trader/strategies/equity_swing.py new file mode 100644 index 0000000..a44e591 --- /dev/null +++ b/algo_trader/strategies/equity_swing.py @@ -0,0 +1 @@ +"""Strategy: Positional equity swing trading logic.""" diff --git a/algo_trader/strategies/intraday_fno.py b/algo_trader/strategies/intraday_fno.py new file mode 100644 index 0000000..d6a490a --- /dev/null +++ b/algo_trader/strategies/intraday_fno.py @@ -0,0 +1 @@ +"""Strategy: Intraday F&O logic.""" diff --git a/algo_trader/ui/dashboard.py b/algo_trader/ui/dashboard.py new file mode 100644 index 0000000..9089202 --- /dev/null +++ b/algo_trader/ui/dashboard.py @@ -0,0 +1,50 @@ +from PySide6.QtWidgets import QWidget, QLabel, QVBoxLayout, QGridLayout +from PySide6.QtCore import Qt + +class DashboardPanel(QWidget): + def __init__(self): + super().__init__() + self.init_ui() + + def init_ui(self): + layout = QVBoxLayout(self) + + # Title + title_label = QLabel("Dashboard") + title_label.setAlignment(Qt.AlignCenter) + title_label.setStyleSheet("font-size: 24px; font-weight: bold; margin-bottom: 20px;") + layout.addWidget(title_label) + + # Metrics Grid + metrics_grid = QGridLayout() + metrics_grid.setSpacing(15) + + # Account Balance + self.account_balance_label = self._create_metric_label("Account Balance:", "$100,000.00") + metrics_grid.addWidget(self.account_balance_label[0], 0, 0) + metrics_grid.addWidget(self.account_balance_label[1], 0, 1) + + # Today's P&L + self.pnl_label = self._create_metric_label("Today's P&L:", "+$500.00 (0.5%)") + metrics_grid.addWidget(self.pnl_label[0], 1, 0) + metrics_grid.addWidget(self.pnl_label[1], 1, 1) + + # Open Positions + self.open_positions_label = self._create_metric_label("Open Positions:", "5") + metrics_grid.addWidget(self.open_positions_label[0], 2, 0) + metrics_grid.addWidget(self.open_positions_label[1], 2, 1) + + # Pending Orders + self.pending_orders_label = self._create_metric_label("Pending Orders:", "2") + metrics_grid.addWidget(self.pending_orders_label[0], 3, 0) + metrics_grid.addWidget(self.pending_orders_label[1], 3, 1) + + layout.addLayout(metrics_grid) + layout.addStretch(1) # Pushes content to the top + self.setLayout(layout) + + def _create_metric_label(self, title, value): + title_label = QLabel(title) + title_label.setStyleSheet("font-weight: bold;") + value_label = QLabel(value) + return title_label, value_label \ No newline at end of file diff --git a/algo_trader/ui/logs_panel.py b/algo_trader/ui/logs_panel.py new file mode 100644 index 0000000..8d193fa --- /dev/null +++ b/algo_trader/ui/logs_panel.py @@ -0,0 +1,26 @@ +from PySide6.QtWidgets import QWidget, QLabel, QVBoxLayout, QTextEdit +from PySide6.QtCore import Qt + +class LogsPanel(QWidget): + def __init__(self): + super().__init__() + self.init_ui() + + def init_ui(self): + layout = QVBoxLayout(self) + + # Title + title_label = QLabel("Logs") + title_label.setAlignment(Qt.AlignCenter) + title_label.setStyleSheet("font-size: 24px; font-weight: bold; margin-bottom: 20px;") + layout.addWidget(title_label) + + self.log_display = QTextEdit() + self.log_display.setReadOnly(True) + layout.addWidget(self.log_display) + + layout.addStretch(1) # Pushes content to the top + self.setLayout(layout) + + def append_log(self, message): + self.log_display.append(message) \ No newline at end of file diff --git a/algo_trader/ui/main_window.py b/algo_trader/ui/main_window.py new file mode 100644 index 0000000..373a8b4 --- /dev/null +++ b/algo_trader/ui/main_window.py @@ -0,0 +1,90 @@ +import sys +import asyncio +from PySide6.QtWidgets import QApplication, QMainWindow, QLabel, QWidget, QVBoxLayout, QTabWidget +from PySide6.QtCore import Qt, QTimer, QThread, Signal + +from .dashboard import DashboardPanel +from .strategy_panel import StrategyPanel +from .positions_panel import PositionsPanel +from .orders_panel import OrdersPanel +from .logs_panel import LogsPanel + + +class AsyncWorker(QThread): + def __init__(self, loop: asyncio.AbstractEventLoop, target_coro, kwargs): + super().__init__() + self.loop = loop + self.target_coro = target_coro + self.kwargs = kwargs + + def run(self): + asyncio.set_event_loop(self.loop) + self.loop.run_until_complete(self.target_coro(**self.kwargs)) + self.loop.close() + + +class MainWindow(QMainWindow): + def __init__( + self, broker, data_feed, db_session, order_manager, risk_manager, strategy_engine, settings, backend_runner + ): + super().__init__() + self.broker = broker + self.data_feed = data_feed + self.db_session = db_session + self.order_manager = order_manager + self.risk_manager = risk_manager + self.strategy_engine = strategy_engine + self.settings = settings + self.backend_runner = backend_runner + + self.setWindowTitle("Algo Trader") + self.setGeometry(100, 100, 1600, 900) + + # Create main tab widget + self.tabs = QTabWidget() + self.tabs.setTabPosition(QTabWidget.North) + + # Create panels + self.dashboard_panel = DashboardPanel() + self.strategy_panel = StrategyPanel() + self.positions_panel = PositionsPanel() + self.orders_panel = OrdersPanel() + self.logs_panel = LogsPanel() + + # Add panels to tabs + self.tabs.addTab(self.dashboard_panel, "Dashboard") + self.tabs.addTab(self.strategy_panel, "Strategies") + self.tabs.addTab(self.positions_panel, "Positions") + self.tabs.addTab(self.orders_panel, "Orders") + self.tabs.addTab(self.logs_panel, "Logs") + + self.setCentralWidget(self.tabs) + + # --- Asyncio Integration --- + self.loop = asyncio.get_event_loop() + if self.loop.is_running(): + self.loop = asyncio.new_event_loop() + + self.backend_worker = AsyncWorker( + loop=self.loop, + target_coro=self.backend_runner, + kwargs={ + "broker": self.broker, + "data_feed": self.data_feed, + "db_session": self.db_session, + "order_manager": self.order_manager, + "risk_manager": self.risk_manager, + "strategy_engine": self.strategy_engine, + "settings": self.settings, + }, + ) + self.backend_worker.start() + + def closeEvent(self, event): + if self.data_feed: + asyncio.run_coroutine_threadsafe(self.data_feed.close(), self.loop) + if self.db_session: + self.db_session.close() + self.loop.call_soon_threadsafe(self.loop.stop) + self.backend_worker.wait() # Wait for the backend thread to finish + super().closeEvent(event) diff --git a/algo_trader/ui/orders_panel.py b/algo_trader/ui/orders_panel.py new file mode 100644 index 0000000..2099b6d --- /dev/null +++ b/algo_trader/ui/orders_panel.py @@ -0,0 +1,37 @@ +from PySide6.QtWidgets import QWidget, QLabel, QVBoxLayout, QTableWidget, QTableWidgetItem, QHeaderView +from PySide6.QtCore import Qt + +class OrdersPanel(QWidget): + def __init__(self): + super().__init__() + self.init_ui() + + def init_ui(self): + layout = QVBoxLayout(self) + + # Title + title_label = QLabel("Orders") + title_label.setAlignment(Qt.AlignCenter) + title_label.setStyleSheet("font-size: 24px; font-weight: bold; margin-bottom: 20px;") + layout.addWidget(title_label) + + self.orders_table = QTableWidget() + self.orders_table.setColumnCount(8) + self.orders_table.setHorizontalHeaderLabels(["Order ID", "Symbol", "Type", "Side", "Quantity", "Price", "Status", "Timestamp"]) + self.orders_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) + layout.addWidget(self.orders_table) + + layout.addStretch(1) # Pushes content to the top + self.setLayout(layout) + + def add_order(self, order_id, symbol, order_type, side, quantity, price, status, timestamp): + row_position = self.orders_table.rowCount() + self.orders_table.insertRow(row_position) + self.orders_table.setItem(row_position, 0, QTableWidgetItem(str(order_id))) + self.orders_table.setItem(row_position, 1, QTableWidgetItem(symbol)) + self.orders_table.setItem(row_position, 2, QTableWidgetItem(order_type)) + self.orders_table.setItem(row_position, 3, QTableWidgetItem(side)) + self.orders_table.setItem(row_position, 4, QTableWidgetItem(str(quantity))) + self.orders_table.setItem(row_position, 5, QTableWidgetItem(str(price))) + self.orders_table.setItem(row_position, 6, QTableWidgetItem(status)) + self.orders_table.setItem(row_position, 7, QTableWidgetItem(str(timestamp))) \ No newline at end of file diff --git a/algo_trader/ui/positions_panel.py b/algo_trader/ui/positions_panel.py new file mode 100644 index 0000000..8be7508 --- /dev/null +++ b/algo_trader/ui/positions_panel.py @@ -0,0 +1,46 @@ +from PySide6.QtWidgets import QWidget, QLabel, QVBoxLayout, QTableWidget, QTableWidgetItem, QHeaderView +from PySide6.QtCore import Qt + +class PositionsPanel(QWidget): + def __init__(self): + super().__init__() + self.init_ui() + + def init_ui(self): + layout = QVBoxLayout(self) + + # Title + title_label = QLabel("Positions") + title_label.setAlignment(Qt.AlignCenter) + title_label.setStyleSheet("font-size: 24px; font-weight: bold; margin-bottom: 20px;") + layout.addWidget(title_label) + + self.positions_table = QTableWidget() + self.positions_table.setColumnCount(6) + self.positions_table.setHorizontalHeaderLabels(["Symbol", "Quantity", "Average Price", "Current Price", "Market Value", "Unrealized P&L"]) + self.positions_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) + layout.addWidget(self.positions_table) + + layout.addStretch(1) # Pushes content to the top + self.setLayout(layout) + + def update_position(self, symbol, quantity, avg_price, current_price, market_value, unrealized_pnl): + # Check if position already exists + for row in range(self.positions_table.rowCount()): + if self.positions_table.item(row, 0).text() == symbol: + self.positions_table.setItem(row, 1, QTableWidgetItem(str(quantity))) + self.positions_table.setItem(row, 2, QTableWidgetItem(str(avg_price))) + self.positions_table.setItem(row, 3, QTableWidgetItem(str(current_price))) + self.positions_table.setItem(row, 4, QTableWidgetItem(str(market_value))) + self.positions_table.setItem(row, 5, QTableWidgetItem(str(unrealized_pnl))) + return + + # Add new position if not found + row_position = self.positions_table.rowCount() + self.positions_table.insertRow(row_position) + self.positions_table.setItem(row_position, 0, QTableWidgetItem(symbol)) + self.positions_table.setItem(row_position, 1, QTableWidgetItem(str(quantity))) + self.positions_table.setItem(row_position, 2, QTableWidgetItem(str(avg_price))) + self.positions_table.setItem(row_position, 3, QTableWidgetItem(str(current_price))) + self.positions_table.setItem(row_position, 4, QTableWidgetItem(str(market_value))) + self.positions_table.setItem(row_position, 5, QTableWidgetItem(str(unrealized_pnl))) \ No newline at end of file diff --git a/algo_trader/ui/strategy_panel.py b/algo_trader/ui/strategy_panel.py new file mode 100644 index 0000000..c64f754 --- /dev/null +++ b/algo_trader/ui/strategy_panel.py @@ -0,0 +1,45 @@ +from PySide6.QtWidgets import QWidget, QLabel, QVBoxLayout, QHBoxLayout, QPushButton, QComboBox +from PySide6.QtCore import Qt + +class StrategyPanel(QWidget): + def __init__(self): + super().__init__() + self.init_ui() + + def init_ui(self): + layout = QVBoxLayout(self) + + # Title + title_label = QLabel("Strategies") + title_label.setAlignment(Qt.AlignCenter) + title_label.setStyleSheet("font-size: 24px; font-weight: bold; margin-bottom: 20px;") + layout.addWidget(title_label) + + # Strategy selection and control + strategy_control_layout = QHBoxLayout() + + strategy_control_layout.addWidget(QLabel("Select Strategy:")) + self.strategy_selector = QComboBox() + self.strategy_selector.addItem("Simple Crossover") # Placeholder strategy + self.strategy_selector.addItem("Intraday FNO") # Placeholder strategy + strategy_control_layout.addWidget(self.strategy_selector) + + self.start_button = QPushButton("Start Strategy") + strategy_control_layout.addWidget(self.start_button) + + self.stop_button = QPushButton("Stop Strategy") + self.stop_button.setEnabled(False) # Initially disabled + strategy_control_layout.addWidget(self.stop_button) + + layout.addLayout(strategy_control_layout) + + # Strategy status + status_layout = QHBoxLayout() + status_layout.addWidget(QLabel("Status:")) + self.strategy_status_label = QLabel("Idle") + status_layout.addWidget(self.strategy_status_label) + status_layout.addStretch(1) + layout.addLayout(status_layout) + + layout.addStretch(1) # Pushes content to the top + self.setLayout(layout) \ No newline at end of file diff --git a/algo_trader/utils/helpers.py b/algo_trader/utils/helpers.py new file mode 100644 index 0000000..86da871 --- /dev/null +++ b/algo_trader/utils/helpers.py @@ -0,0 +1 @@ +"""Utils: Helper functions.""" diff --git a/algo_trader/utils/logger.py b/algo_trader/utils/logger.py new file mode 100644 index 0000000..499038b --- /dev/null +++ b/algo_trader/utils/logger.py @@ -0,0 +1 @@ +"""Utils: Logging configuration."""