Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,7 @@ __pycache__/
*.pyc
.venv/
*.log
.idea/
.vscode/
.DS_Store

6 changes: 6 additions & 0 deletions bot/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from .handlers import (
action_plan_command,
analyse_command,
cashflow_command,
funds_command,
help_command,
menu_callback_handler,
Expand All @@ -16,6 +17,7 @@
price_command,
refresh_session_command,
signals_command,
sips_command,
start_command,
status_command,
watchlist_command,
Expand All @@ -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"),
Expand All @@ -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))
Expand Down
68 changes: 68 additions & 0 deletions bot/formatters.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,71 @@ def format_stock_analysis_message(ticker, signal_row, action_row, quote):
if action_row:
lines.append(f"\n<b>Verdict: {action_row['action']}</b>\n<i>{action_row['rationale']}</i>")
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"📋 <b>Active SIP Book ({len(sips)} Instruments)</b>\n"
msg += f"<b>Total Monthly Inflow:</b> ₹{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", "🟢 <b>CONTINUE / BUY MORE</b>"),
("HOLD / ACCUMULATE", "⚪ <b>HOLD / STEADY</b>"),
("TRIM / PAUSE SIP", "🟡 <b>PAUSE SIP / TRIM</b>"),
("SELL / STOP SIP", "🔴 <b>STOP SIP / REALLOCATE</b>"),
]

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" • <code>{ticker}</code>: ₹{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 += "<b>Other Instruments:</b>\n"
for item in other_items:
msg += f" • <code>{item.get('ticker')}</code>: ₹{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 = "💰 <b>Portfolio Dividend & Cashflow Engine</b>\n\n"
msg += f"<b>Estimated Annual Cashflow:</b> ₹{total_annual:,.0f} / year\n"
msg += f"<b>Monthly Baseline Run-rate:</b> ~₹{monthly_runrate:,.0f} / month\n\n"

msg += "<b>Top Yield-on-Cost (YoC) Generators:</b>\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" • <code>{ticker}</code>: YoC <b>{yoc:.2f}%</b> (₹{est:,.0f}/yr, {freq})\n"

msg += "\n<i>Projections scale automatically as monthly SIPs compound.</i>"
return msg

32 changes: 31 additions & 1 deletion bot/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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 &lt;TICKER&gt; - Manage watchlist\n"
"/price &lt;TICKER&gt; - Get current stock price\n"
"/funds - View available funds\n"
Expand All @@ -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()
Expand Down Expand Up @@ -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))
Expand All @@ -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)
Expand Down
31 changes: 31 additions & 0 deletions core/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
29 changes: 29 additions & 0 deletions scripts/init_supabase_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
)
""",
]


Expand Down
Loading