From c638396887345909d0a26b4823a9c11d2d1806b6 Mon Sep 17 00:00:00 2001 From: Abhinav Prakash Date: Sun, 16 Aug 2026 07:30:27 +0530 Subject: [PATCH 1/2] feat: add sip_book and dividend_schedule schema with /sips and /cashflow bot commands --- bot/app.py | 6 +++ bot/formatters.py | 68 +++++++++++++++++++++++++++++++++ bot/handlers.py | 32 +++++++++++++++- core/database.py | 31 +++++++++++++++ scripts/init_supabase_schema.py | 29 ++++++++++++++ 5 files changed, 165 insertions(+), 1 deletion(-) diff --git a/bot/app.py b/bot/app.py index f5ba8ce..e068a19 100644 --- a/bot/app.py +++ b/bot/app.py @@ -8,6 +8,7 @@ from .handlers import ( action_plan_command, analyse_command, + cashflow_command, funds_command, help_command, menu_callback_handler, @@ -16,6 +17,7 @@ price_command, refresh_session_command, signals_command, + sips_command, start_command, status_command, watchlist_command, @@ -26,6 +28,8 @@ BotCommand("menu", "Show interactive menu"), BotCommand("portfolio", "View current holdings"), BotCommand("signals", "View today's signals"), + BotCommand("sips", "View active SIP book & quant guidance"), + BotCommand("cashflow", "View dividend & cashflow projections"), BotCommand("analyse", "Full analysis for one ticker"), BotCommand("action_plan", "View buy/sell/hold verdicts"), BotCommand("watchlist", "Manage watchlist"), @@ -47,6 +51,8 @@ def create_app() -> Application: app.add_handler(CallbackQueryHandler(menu_callback_handler)) app.add_handler(CommandHandler("portfolio", portfolio_command)) app.add_handler(CommandHandler("signals", signals_command)) + app.add_handler(CommandHandler("sips", sips_command)) + app.add_handler(CommandHandler("cashflow", cashflow_command)) app.add_handler(CommandHandler("watchlist", watchlist_command)) app.add_handler(CommandHandler("price", price_command)) app.add_handler(CommandHandler("funds", funds_command)) diff --git a/bot/formatters.py b/bot/formatters.py index 9e1f37c..bbcf28b 100644 --- a/bot/formatters.py +++ b/bot/formatters.py @@ -64,3 +64,71 @@ def format_stock_analysis_message(ticker, signal_row, action_row, quote): if action_row: lines.append(f"\nVerdict: {action_row['action']}\n{action_row['rationale']}") return "\n".join(lines) + + +def format_sips_message(sips: list) -> str: + if not sips: + return "No active SIPs found in the database." + + total_monthly = sum(s.get('monthly_sip', 0) for s in sips) + msg = f"📋 Active SIP Book ({len(sips)} Instruments)\n" + msg += f"Total Monthly Inflow: ₹{total_monthly:,.0f}\n\n" + + # Group by target_action + grouped = {} + for s in sips: + action = s.get('target_action') or 'HOLD' + grouped.setdefault(action, []).append(s) + + order = [ + ("BUY MORE / CONTINUE", "🟢 CONTINUE / BUY MORE"), + ("HOLD / ACCUMULATE", "⚪ HOLD / STEADY"), + ("TRIM / PAUSE SIP", "🟡 PAUSE SIP / TRIM"), + ("SELL / STOP SIP", "🔴 STOP SIP / REALLOCATE"), + ] + + for action_key, header in order: + items = grouped.get(action_key, []) + if items: + subtotal = sum(i.get('monthly_sip', 0) for i in items) + msg += f"{header} (₹{subtotal:,.0f}/mo):\n" + for item in items: + ticker = item.get('ticker') + amount = item.get('monthly_sip', 0) + msg += f" • {ticker}: ₹{amount:,.0f}/mo\n" + msg += "\n" + + # Any remaining unlisted actions + known_keys = {k for k, _ in order} + other_items = [s for s in sips if (s.get('target_action') or 'HOLD') not in known_keys] + if other_items: + msg += "Other Instruments:\n" + for item in other_items: + msg += f" • {item.get('ticker')}: ₹{item.get('monthly_sip', 0):,.0f}/mo ({item.get('target_action')})\n" + + return msg.strip() + + +def format_cashflow_message(dividend_rows: list, total_holdings_count: int = 0) -> str: + if not dividend_rows: + return "No dividend schedule data available." + + total_annual = sum(r.get('est_annual_cashflow', 0) for r in dividend_rows) + monthly_runrate = total_annual / 12.0 + + msg = "💰 Portfolio Dividend & Cashflow Engine\n\n" + msg += f"Estimated Annual Cashflow: ₹{total_annual:,.0f} / year\n" + msg += f"Monthly Baseline Run-rate: ~₹{monthly_runrate:,.0f} / month\n\n" + + msg += "Top Yield-on-Cost (YoC) Generators:\n" + sorted_yoc = sorted(dividend_rows, key=lambda x: x.get('yield_on_cost', 0), reverse=True) + for r in sorted_yoc[:8]: + ticker = r.get('ticker') + yoc = r.get('yield_on_cost', 0) + est = r.get('est_annual_cashflow', 0) + freq = r.get('payout_frequency', 'PERIODIC') + msg += f" • {ticker}: YoC {yoc:.2f}% (₹{est:,.0f}/yr, {freq})\n" + + msg += "\nProjections scale automatically as monthly SIPs compound." + return msg + diff --git a/bot/handlers.py b/bot/handlers.py index 619eb34..6ffe9f4 100644 --- a/bot/handlers.py +++ b/bot/handlers.py @@ -10,7 +10,12 @@ from core.db import get_pool from core.session_store import get_session, save_session -from .formatters import format_portfolio_message, format_signals_message +from .formatters import ( + format_cashflow_message, + format_portfolio_message, + format_signals_message, + format_sips_message, +) from .middleware import owner_only logger = logging.getLogger(__name__) @@ -233,6 +238,8 @@ async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE): "/portfolio - View current holdings\n" "/signals - View today's signals\n" "/action_plan - View portfolio action classification\n" + "/sips - View active SIP book & quant allocation\n" + "/cashflow - View dividend & cashflow projections\n" "/watchlist list|add|remove <TICKER> - Manage watchlist\n" "/price <TICKER> - Get current stock price\n" "/funds - View available funds\n" @@ -243,6 +250,26 @@ async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE): ) await update.message.reply_text(help_text, parse_mode='HTML') +@owner_only +async def sips_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + pool = get_pool() + async with pool.acquire() as db: + rows = await db.fetch("SELECT * FROM sip_book ORDER BY monthly_sip DESC") + + sips = [dict(r) for r in rows] + msg = format_sips_message(sips) + await update.message.reply_text(msg, parse_mode='HTML') + +@owner_only +async def cashflow_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + pool = get_pool() + async with pool.acquire() as db: + rows = await db.fetch("SELECT * FROM dividend_schedule ORDER BY yield_on_cost DESC") + + dividend_rows = [dict(r) for r in rows] + msg = format_cashflow_message(dividend_rows) + await update.message.reply_text(msg, parse_mode='HTML') + @owner_only async def action_plan_command(update: Update, context: ContextTypes.DEFAULT_TYPE): pool = get_pool() @@ -296,6 +323,7 @@ async def menu_command(update: Update, context: ContextTypes.DEFAULT_TYPE): keyboard = [ [InlineKeyboardButton("📊 Portfolio", callback_data="portfolio"), InlineKeyboardButton("📈 Signals", callback_data="signals")], [InlineKeyboardButton("🎯 Action Plan", callback_data="action_plan"), InlineKeyboardButton("💰 Funds", callback_data="funds")], + [InlineKeyboardButton("📋 SIP Book", callback_data="sips"), InlineKeyboardButton("💵 Cashflow", callback_data="cashflow")], [InlineKeyboardButton("⚙️ Status", callback_data="status")], ] await update.message.reply_text("Choose an option:", reply_markup=InlineKeyboardMarkup(keyboard)) @@ -309,6 +337,8 @@ async def menu_callback_handler(update: Update, context: ContextTypes.DEFAULT_TY "signals": signals_command, "action_plan": action_plan_command, "funds": funds_command, + "sips": sips_command, + "cashflow": cashflow_command, "status": status_command, } handler = dispatch.get(query.data) diff --git a/core/database.py b/core/database.py index 0656466..1d6a42c 100644 --- a/core/database.py +++ b/core/database.py @@ -155,4 +155,35 @@ async def init_db(): ) """) + await db.execute(""" + CREATE TABLE IF NOT EXISTS sip_book ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + ticker TEXT NOT NULL UNIQUE, + stock_code TEXT, + asset_category TEXT NOT NULL, + monthly_sip DOUBLE PRECISION NOT NULL, + execution_day INTEGER DEFAULT 31, + status TEXT DEFAULT 'ACTIVE', + quant_stage INTEGER DEFAULT 2, + target_action TEXT DEFAULT 'CONTINUE', + updated_at TIMESTAMPTZ DEFAULT now() + ) + """) + + await db.execute(""" + CREATE TABLE IF NOT EXISTS dividend_schedule ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + ticker TEXT NOT NULL, + stock_code TEXT, + asset_type TEXT NOT NULL, + expected_dpu DOUBLE PRECISION NOT NULL, + payout_frequency TEXT, + last_ex_date TEXT, + next_expected_payout TEXT, + est_annual_cashflow DOUBLE PRECISION, + yield_on_cost DOUBLE PRECISION, + updated_at TIMESTAMPTZ DEFAULT now() + ) + """) + logger.info("Database tables initialized.") diff --git a/scripts/init_supabase_schema.py b/scripts/init_supabase_schema.py index ffbda81..e864e97 100644 --- a/scripts/init_supabase_schema.py +++ b/scripts/init_supabase_schema.py @@ -169,6 +169,35 @@ async def connect_with_retry(dsn): updated_at TIMESTAMPTZ DEFAULT now() ) """, + """ + CREATE TABLE IF NOT EXISTS sip_book ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + ticker TEXT NOT NULL UNIQUE, + stock_code TEXT, + asset_category TEXT NOT NULL, + monthly_sip DOUBLE PRECISION NOT NULL, + execution_day INTEGER DEFAULT 31, + status TEXT DEFAULT 'ACTIVE', + quant_stage INTEGER DEFAULT 2, + target_action TEXT DEFAULT 'CONTINUE', + updated_at TIMESTAMPTZ DEFAULT now() + ) + """, + """ + CREATE TABLE IF NOT EXISTS dividend_schedule ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + ticker TEXT NOT NULL, + stock_code TEXT, + asset_type TEXT NOT NULL, + expected_dpu DOUBLE PRECISION NOT NULL, + payout_frequency TEXT, + last_ex_date TEXT, + next_expected_payout TEXT, + est_annual_cashflow DOUBLE PRECISION, + yield_on_cost DOUBLE PRECISION, + updated_at TIMESTAMPTZ DEFAULT now() + ) + """, ] From e5382650f0e61ef282e4fc8316ea8ab2142e33fd Mon Sep 17 00:00:00 2001 From: Abhinav Prakash Date: Sun, 16 Aug 2026 07:32:04 +0530 Subject: [PATCH 2/2] chore: ignore .idea, .vscode, and OS files in .gitignore --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 4f13ae6..69d07ac 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,7 @@ __pycache__/ *.pyc .venv/ *.log +.idea/ +.vscode/ +.DS_Store +