bfinance 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
bfinance/__init__.py ADDED
@@ -0,0 +1,64 @@
1
+ """
2
+ bfinance — High-performance Python SDK for Indian Equities (NSE & BSE).
3
+ A 1:1 drop-in replacement for yfinance (1.7.0+ compatible) supercharged with 10-year financials, concalls, and live market microstructure.
4
+ """
5
+
6
+ from .ticker import Ticker
7
+ from .tickers import Tickers
8
+ from .screens import Screen, screens
9
+ from .download import download
10
+ from .sector import Sector, Industry
11
+ from .screener.client import ScreenerClient
12
+ from .market.fast_info import FastInfo
13
+ from .market.ratios import ScreenerRatioSearch, CustomRatiosCalculator
14
+ from .models.company import CompanyProfile, Concall, PeerStock, TopRatios
15
+ from .models.options import OptionChain, OptionContract
16
+ from .models.statements import FinancialStatement
17
+ from .utils.excel import FinancialModelExcelExporter
18
+ from .utils.downloader import DocumentDownloader
19
+ from .ai.context import AIContextBuilder
20
+ from .ai.prompts import AIPromptFactory
21
+ from .ai.tools import BFinanceAITools
22
+ from .utils.exceptions import (
23
+ BFinanceError,
24
+ TickerNotFoundError,
25
+ UpstreamServiceError,
26
+ RateLimitExceededError,
27
+ )
28
+
29
+ ratios = ScreenerRatioSearch()
30
+
31
+ __version__ = "0.1.0"
32
+ __author__ = "bfinance contributors"
33
+
34
+ __all__ = [
35
+ "Ticker",
36
+ "Tickers",
37
+ "Sector",
38
+ "Industry",
39
+ "Screen",
40
+ "screens",
41
+ "ratios",
42
+ "download",
43
+ "FastInfo",
44
+ "ScreenerClient",
45
+ "CompanyProfile",
46
+ "Concall",
47
+ "PeerStock",
48
+ "TopRatios",
49
+ "OptionChain",
50
+ "OptionContract",
51
+ "FinancialStatement",
52
+ "FinancialModelExcelExporter",
53
+ "DocumentDownloader",
54
+ "AIContextBuilder",
55
+ "AIPromptFactory",
56
+ "BFinanceAITools",
57
+ "ScreenerRatioSearch",
58
+ "CustomRatiosCalculator",
59
+ "BFinanceError",
60
+ "TickerNotFoundError",
61
+ "UpstreamServiceError",
62
+ "RateLimitExceededError",
63
+ "__version__",
64
+ ]
@@ -0,0 +1,13 @@
1
+ """
2
+ AI Integration Engine for bfinance: Context generation, Institutional Prompt Templates, and Agent Tool Schemas.
3
+ """
4
+
5
+ from .context import AIContextBuilder
6
+ from .prompts import AIPromptFactory
7
+ from .tools import BFinanceAITools
8
+
9
+ __all__ = [
10
+ "AIContextBuilder",
11
+ "AIPromptFactory",
12
+ "BFinanceAITools",
13
+ ]
bfinance/ai/context.py ADDED
@@ -0,0 +1,194 @@
1
+ """
2
+ AI Context Engine: Formats and serializes 100% of bfinance corporate data into
3
+ token-optimized, highly structured Markdown, JSON, and text representations for LLMs.
4
+ """
5
+
6
+ from typing import Any, Dict, List, Literal, Optional, Set
7
+ import json
8
+ import pandas as pd
9
+
10
+ from bfinance.models.company import CompanyProfile
11
+
12
+
13
+ class AIContextBuilder:
14
+ """
15
+ Constructs comprehensive AI-ready context dossiers from CompanyProfile.
16
+ Supports token-efficient Markdown, JSON Schema, and plain dense text.
17
+ """
18
+
19
+ @classmethod
20
+ def build_markdown_context(
21
+ cls,
22
+ profile: CompanyProfile,
23
+ include_sections: Optional[Set[str]] = None,
24
+ max_quarters: int = 8,
25
+ max_years: int = 7,
26
+ max_concalls: int = 5,
27
+ ) -> str:
28
+ """
29
+ Generate dense, professional Markdown formatted financial context for LLMs.
30
+ """
31
+ all_sections = {
32
+ "metadata", "valuation", "income_stmt", "quarters",
33
+ "balance_sheet", "cash_flow", "shareholding", "ratios",
34
+ "cagrs", "analysis", "concalls", "peers", "documents"
35
+ }
36
+ active = include_sections or all_sections
37
+
38
+ lines: List[str] = []
39
+ r = profile.ratios
40
+
41
+ # 1. Metadata
42
+ if "metadata" in active:
43
+ lines.append(f"# FINANCIAL DOSSIER: {profile.name} (NSE: {profile.nse_symbol or profile.symbol} | BSE: {profile.bse_code or 'N/A'})")
44
+ lines.append(f"**Sector**: {profile.sector or 'N/A'} | **Industry**: {profile.industry or 'N/A'} | **Sub-Industry**: {profile.sub_industry or 'N/A'}")
45
+ if profile.indices:
46
+ lines.append(f"**Index Memberships**: {', '.join(profile.indices[:6])}")
47
+ if profile.about:
48
+ lines.append(f"\n### Business Description\n{profile.about}")
49
+
50
+ # 2. Key Valuation & Financial Ratios
51
+ if "valuation" in active:
52
+ lines.append("\n## Current Valuation & Quality Metrics")
53
+ lines.append(f"- **Current Market Price (CMP)**: ₹{r.current_price:,.2f}" if r.current_price else "- **CMP**: N/A")
54
+ lines.append(f"- **Market Capitalization**: ₹{r.market_cap:,.2f} Cr" if r.market_cap else "- **Market Cap**: N/A")
55
+ lines.append(f"- **52-Week Range**: ₹{r.low_52w:,.2f} - ₹{r.high_52w:,.2f}" if r.low_52w and r.high_52w else "")
56
+ lines.append(f"- **Stock P/E**: {r.stock_pe:.2f}x | **Book Value**: ₹{r.book_value:,.2f}" if r.stock_pe and r.book_value else "")
57
+ lines.append(f"- **ROCE**: {r.roce:.2f}% | **ROE**: {r.roe:.2f}%" if r.roce and r.roe else "")
58
+ lines.append(f"- **Dividend Yield**: {r.dividend_yield:.2f}% | **Face Value**: ₹{r.face_value}" if r.dividend_yield is not None else "")
59
+ if r.debt_to_equity is not None:
60
+ lines.append(f"- **Debt to Equity**: {r.debt_to_equity:.2f}")
61
+
62
+ # 3. Annual Income Statement
63
+ if "income_stmt" in active and not profile.profit_loss.empty:
64
+ lines.append(f"\n## Annual Income Statement (Last {max_years} Years in ₹ Cr)")
65
+ df_pnl = profile.profit_loss.to_dataframe(orient="columns")
66
+ if not df_pnl.empty:
67
+ cols = list(df_pnl.columns)[-max_years:]
68
+ lines.append(df_pnl[cols].to_markdown())
69
+
70
+ # 4. Quarterly Results
71
+ if "quarters" in active and not profile.quarters.empty:
72
+ lines.append(f"\n## Quarterly Financial Results (Last {max_quarters} Quarters in ₹ Cr)")
73
+ df_q = profile.quarters.to_dataframe(orient="columns")
74
+ if not df_q.empty:
75
+ cols = list(df_q.columns)[-max_quarters:]
76
+ lines.append(df_q[cols].to_markdown())
77
+
78
+ # 5. Balance Sheet
79
+ if "balance_sheet" in active and not profile.balance_sheet.empty:
80
+ lines.append(f"\n## Annual Balance Sheet (Last {max_years} Years in ₹ Cr)")
81
+ df_bs = profile.balance_sheet.to_dataframe(orient="columns")
82
+ if not df_bs.empty:
83
+ cols = list(df_bs.columns)[-max_years:]
84
+ lines.append(df_bs[cols].to_markdown())
85
+
86
+ # 6. Cash Flow Statement
87
+ if "cash_flow" in active and not profile.cash_flow.empty:
88
+ lines.append(f"\n## Cash Flow Statement (Last {max_years} Years in ₹ Cr)")
89
+ df_cf = profile.cash_flow.to_dataframe(orient="columns")
90
+ if not df_cf.empty:
91
+ cols = list(df_cf.columns)[-max_years:]
92
+ lines.append(df_cf[cols].to_markdown())
93
+
94
+ # 7. Shareholding Pattern Trends
95
+ if "shareholding" in active:
96
+ lines.append("\n## Institutional Shareholding Distribution (%)")
97
+ df_sh = profile.shareholding.to_dataframe(orient="columns")
98
+ if not df_sh.empty:
99
+ lines.append("### Quarterly Trend (Recent Quarters)")
100
+ lines.append(df_sh.iloc[:, -6:].to_markdown())
101
+
102
+ df_shy = profile.shareholding_yearly.to_dataframe(orient="columns")
103
+ if not df_shy.empty:
104
+ lines.append("\n### 10-Year Annual Trend")
105
+ lines.append(df_shy.iloc[:, -6:].to_markdown())
106
+
107
+ # 8. Operating Ratios History
108
+ if "ratios" in active and not profile.ratios_history.empty:
109
+ lines.append("\n## Historical Operating Ratios & Efficiency Metrics")
110
+ df_rh = profile.ratios_history.to_dataframe(orient="columns")
111
+ if not df_rh.empty:
112
+ lines.append(df_rh.iloc[:, -max_years:].to_markdown())
113
+
114
+ # 9. Compounded CAGRs
115
+ if "cagrs" in active and profile.cagrs:
116
+ lines.append("\n## Compounded Annual Growth Rates (CAGR %)")
117
+ for k, v in profile.cagrs.items():
118
+ vals = " | ".join([f"{time_k}: {val}" for time_k, val in v.items()])
119
+ lines.append(f"- **{k}**: {vals}")
120
+
121
+ # 10. Qualitative Insights (Pros & Cons)
122
+ if "analysis" in active and (profile.analysis.pros or profile.analysis.cons):
123
+ lines.append("\n## Qualitative Analysis (Pros & Cons)")
124
+ if profile.analysis.pros:
125
+ lines.append("### Key Strengths (Pros)")
126
+ for p in profile.analysis.pros:
127
+ lines.append(f"- [PRO] {p}")
128
+ if profile.analysis.cons:
129
+ lines.append("### Key Risks & Concerns (Cons)")
130
+ for c in profile.analysis.cons:
131
+ lines.append(f"- [CON] {c}")
132
+
133
+ # 11. Earnings Conference Calls
134
+ if "concalls" in active and profile.concalls:
135
+ lines.append(f"\n## Recent Conference Calls & Transcripts (Latest {max_concalls})")
136
+ for call in profile.concalls[:max_concalls]:
137
+ audio_tag = f" | [Audio MP3]({call.audio_url})" if call.audio_url else ""
138
+ transcript_tag = f" | [PDF Transcript]({call.transcript_url})" if call.transcript_url else ""
139
+ lines.append(f"- **{call.date}**: {call.title}{transcript_tag}{audio_tag}")
140
+
141
+ # 12. Peer Benchmarking
142
+ if "peers" in active and profile.peers:
143
+ lines.append("\n## Industry Peer Comparison Matrix")
144
+ df_peers = profile.peers_dataframe()
145
+ if not df_peers.empty:
146
+ peer_cols = [c for c in ["rank", "name", "cmp", "pe", "market_cap_cr", "roce", "dividend_yield"] if c in df_peers.columns]
147
+ lines.append(df_peers[peer_cols].head(8).to_markdown(index=False))
148
+
149
+ return "\n".join([line for line in lines if line.strip() != ""])
150
+
151
+ @classmethod
152
+ def build_json_context(cls, profile: CompanyProfile) -> Dict[str, Any]:
153
+ """
154
+ Generate structured nested JSON dictionary format for tool outputs and function calls.
155
+ """
156
+ r = profile.ratios
157
+ return {
158
+ "symbol": profile.symbol,
159
+ "company_name": profile.name,
160
+ "bse_code": profile.bse_code,
161
+ "nse_symbol": profile.nse_symbol,
162
+ "sector": profile.sector,
163
+ "industry": profile.industry,
164
+ "sub_industry": profile.sub_industry,
165
+ "indices": profile.indices,
166
+ "about": profile.about,
167
+ "valuation": {
168
+ "current_price": r.current_price,
169
+ "market_cap_cr": r.market_cap,
170
+ "high_52w": r.high_52w,
171
+ "low_52w": r.low_52w,
172
+ "pe_ratio": r.stock_pe,
173
+ "book_value": r.book_value,
174
+ "dividend_yield_pct": r.dividend_yield,
175
+ "roce_pct": r.roce,
176
+ "roe_pct": r.roe,
177
+ "face_value": r.face_value,
178
+ "debt_to_equity": r.debt_to_equity,
179
+ "peg_ratio": r.peg_ratio,
180
+ },
181
+ "cagrs": profile.cagrs,
182
+ "pros_and_cons": {
183
+ "pros": profile.analysis.pros,
184
+ "cons": profile.analysis.cons,
185
+ },
186
+ "annual_pnl": profile.profit_loss.rows,
187
+ "quarterly_pnl": profile.quarters.rows,
188
+ "balance_sheet": profile.balance_sheet.rows,
189
+ "cash_flow": profile.cash_flow.rows,
190
+ "shareholding_quarterly": profile.shareholding.rows,
191
+ "shareholding_yearly": profile.shareholding_yearly.rows,
192
+ "concalls": [c.model_dump() for c in profile.concalls[:10]],
193
+ "peers": [p.model_dump() for p in profile.peers[:8]],
194
+ }
bfinance/ai/prompts.py ADDED
@@ -0,0 +1,81 @@
1
+ """
2
+ Institutional LLM Prompt Templates for Financial Analysis and Decision-Making.
3
+ """
4
+
5
+ from typing import Optional
6
+ from bfinance.models.company import CompanyProfile
7
+ from bfinance.ai.context import AIContextBuilder
8
+
9
+
10
+ class AIPromptFactory:
11
+ """
12
+ Constructs institutional-grade system and user prompts for AI equity analysis.
13
+ """
14
+
15
+ @classmethod
16
+ def investment_memo(cls, profile: CompanyProfile, custom_instructions: str = "") -> str:
17
+ """
18
+ Generate an institutional initiation coverage / investment memo prompt.
19
+ """
20
+ context = AIContextBuilder.build_markdown_context(profile)
21
+ instructions = custom_instructions or (
22
+ "You are a Senior Principal at a top-tier Indian Long-Only Equity Fund.\n"
23
+ "Analyze the provided financial dossier and draft a rigorous Investment Initiation Note.\n"
24
+ "Include:\n"
25
+ "1. Executive Summary & Investment Thesis (Key Moats, Catalysts, Risks)\n"
26
+ "2. Business Model & Industry Dynamics (Competitive Positioning, Pricing Power)\n"
27
+ "3. Financial Quality Breakdown (Revenue Quality, Margin Durability, Capital Allocation, ROCE trends)\n"
28
+ "4. Capital Structure & Balance Sheet Health (Working capital, debt trajectory, Cash Conversion Cycle)\n"
29
+ "5. Ownership & Institutional Flow Analysis (Promoter pledge, FII vs DII trajectory)\n"
30
+ "6. Valuation Multiples & Fair Value Range (Historical PE vs Growth, DCF expectations)\n"
31
+ "7. Key Downside Risks & Invalidation Triggers"
32
+ )
33
+
34
+ return (
35
+ f"<INSTRUCTIONS>\n{instructions}\n</INSTRUCTIONS>\n\n"
36
+ f"<COMPANY_FINANCIAL_DOSSIER>\n{context}\n</COMPANY_FINANCIAL_DOSSIER>\n\n"
37
+ "Please write the comprehensive investment memo now."
38
+ )
39
+
40
+ @classmethod
41
+ def forensic_audit(cls, profile: CompanyProfile) -> str:
42
+ """
43
+ Generate a forensic accounting audit prompt to identify red flags and earnings manipulation.
44
+ """
45
+ context = AIContextBuilder.build_markdown_context(profile)
46
+ return (
47
+ "<INSTRUCTIONS>\n"
48
+ "You are an expert Forensic Accounting Auditor specializing in Indian listed equities.\n"
49
+ "Perform a forensic quality check on the provided financial statements.\n"
50
+ "Specifically examine and score (1 to 10):\n"
51
+ "1. Cash Flow vs Reported PAT Divergence (Is Operating Cash Flow lagging Net Profit?)\n"
52
+ "2. Working Capital Stress (Are Debtor Days or Inventory Days surging?)\n"
53
+ "3. Other Income Dependency (Is Other Income inflating Operating PBT?)\n"
54
+ "4. Depreciation & CWIP Capitalization Shenanigans (Is Capex stuck in CWIP?)\n"
55
+ "5. Promoter Pledge & Stake Dilution\n"
56
+ "6. Auditor Observations or Material Related Party Transactions (if indicated in notes)\n"
57
+ "Provide a final 'Forensic Risk Rating' (Low, Medium, High, Extreme Red Flag) with detailed justification.\n"
58
+ "</INSTRUCTIONS>\n\n"
59
+ f"<COMPANY_FINANCIAL_DOSSIER>\n{context}\n</COMPANY_FINANCIAL_DOSSIER>\n\n"
60
+ "Please conduct the forensic audit now."
61
+ )
62
+
63
+ @classmethod
64
+ def concall_summary(cls, profile: CompanyProfile) -> str:
65
+ """
66
+ Generate prompt focusing on management commentary, concalls, and forward guidance.
67
+ """
68
+ context = AIContextBuilder.build_markdown_context(profile)
69
+ return (
70
+ "<INSTRUCTIONS>\n"
71
+ "You are an Equity Research Analyst covering Indian Markets.\n"
72
+ "Analyze the latest earnings conference calls, quarterly performance, and management commentary.\n"
73
+ "Extract:\n"
74
+ "1. Core Operational Highlights for the Latest Quarter\n"
75
+ "2. Forward Revenue, Margin, and Capex Guidance given by Management\n"
76
+ "3. Raw Q&A Analysis: Tough questions asked by institutional analysts and management's answers\n"
77
+ "4. Headwinds vs Tailwinds summary\n"
78
+ "</INSTRUCTIONS>\n\n"
79
+ f"<COMPANY_FINANCIAL_DOSSIER>\n{context}\n</COMPANY_FINANCIAL_DOSSIER>\n\n"
80
+ "Please summarize the management commentary and forward guidance now."
81
+ )
bfinance/ai/tools.py ADDED
@@ -0,0 +1,93 @@
1
+ """
2
+ Agent Function Calling & Tool Specifications for OpenAI, Gemini, Anthropic, LangChain, and LiteLLM.
3
+ """
4
+
5
+ from typing import Any, Callable, Dict, List, Optional
6
+ import json
7
+ from bfinance.ticker import Ticker
8
+ from bfinance.screens import screens
9
+ from bfinance.ai.context import AIContextBuilder
10
+
11
+
12
+ class BFinanceAITools:
13
+ """
14
+ Standard Function Calling / Tool specifications for AI Agents.
15
+ """
16
+
17
+ @classmethod
18
+ def get_openai_tools(cls) -> List[Dict[str, Any]]:
19
+ """
20
+ Return OpenAI / Gemini / LiteLLM function calling schemas.
21
+ """
22
+ return [
23
+ {
24
+ "type": "function",
25
+ "function": {
26
+ "name": "get_stock_dossier",
27
+ "description": "Fetch 100% of financial statements, valuation ratios, 10Y CAGRs, shareholding, and concall media for an Indian stock (NSE/BSE).",
28
+ "parameters": {
29
+ "type": "object",
30
+ "properties": {
31
+ "symbol": {
32
+ "type": "string",
33
+ "description": "NSE or BSE stock symbol, e.g. 'RELIANCE', 'TCS', 'HDFCBANK', 'BAJAJ-AUTO'."
34
+ },
35
+ "format": {
36
+ "type": "string",
37
+ "enum": ["markdown", "json"],
38
+ "description": "Output format ('markdown' for direct LLM reasoning, 'json' for structured processing).",
39
+ "default": "markdown"
40
+ }
41
+ },
42
+ "required": ["symbol"]
43
+ }
44
+ }
45
+ },
46
+ {
47
+ "type": "function",
48
+ "function": {
49
+ "name": "run_institutional_screen",
50
+ "description": "Run institutional quantitative stock screener across Indian equities (Coffee Can, Magic Formula, Debt Free Compounders, High Dividend, Undervalued Growth).",
51
+ "parameters": {
52
+ "type": "object",
53
+ "properties": {
54
+ "screen_name": {
55
+ "type": "string",
56
+ "enum": ["coffee_can", "magic_formula", "debt_free_compounders", "high_dividend_yield", "undervalued_growth"],
57
+ "description": "The institutional strategy to execute."
58
+ },
59
+ "max_stocks": {
60
+ "type": "integer",
61
+ "description": "Maximum matching stocks to return.",
62
+ "default": 10
63
+ }
64
+ },
65
+ "required": ["screen_name"]
66
+ }
67
+ }
68
+ }
69
+ ]
70
+
71
+ @classmethod
72
+ def execute_tool(cls, name: str, arguments: Dict[str, Any]) -> str:
73
+ """
74
+ Execute an AI tool call and return serialized string output.
75
+ """
76
+ if name == "get_stock_dossier":
77
+ symbol = arguments.get("symbol", "")
78
+ out_fmt = arguments.get("format", "markdown")
79
+ t = Ticker(symbol)
80
+ if out_fmt == "json":
81
+ return json.dumps(t.to_ai_context(format="json"), indent=2)
82
+ return t.to_ai_context(format="markdown")
83
+
84
+ elif name == "run_institutional_screen":
85
+ screen_name = arguments.get("screen_name", "coffee_can")
86
+ max_stocks = arguments.get("max_stocks", 10)
87
+ screen_obj = getattr(screens, screen_name, None)
88
+ if not screen_obj:
89
+ return f"Error: Screen '{screen_name}' not found."
90
+ df = screen_obj.run(max_stocks=max_stocks)
91
+ return df.to_markdown(index=False)
92
+
93
+ return f"Error: Unknown tool function '{name}'."
@@ -0,0 +1,7 @@
1
+ """
2
+ Cache layer for bfinance.
3
+ """
4
+
5
+ from .sqlite_cache import SQLiteCache
6
+
7
+ __all__ = ["SQLiteCache"]
@@ -0,0 +1,121 @@
1
+ """
2
+ Persistent SQLite caching layer with TTL support for bfinance.
3
+ """
4
+
5
+ import json
6
+ import os
7
+ import sqlite3
8
+ import time
9
+ from typing import Any, Optional
10
+ from pathlib import Path
11
+
12
+
13
+ class SQLiteCache:
14
+ """
15
+ Lightweight, embedded SQLite cache for bfinance upstream queries.
16
+ Stores serialized JSON payloads with expiration timestamps.
17
+ """
18
+
19
+ def __init__(self, db_path: Optional[str] = None, default_ttl_hours: float = 24.0):
20
+ if db_path is None:
21
+ # Store in user home directory under ~/.bfinance/cache.db
22
+ home_dir = Path.home() / ".bfinance"
23
+ home_dir.mkdir(parents=True, exist_ok=True)
24
+ self.db_path = str(home_dir / "cache.db")
25
+ else:
26
+ self.db_path = db_path
27
+
28
+ self.default_ttl_hours = default_ttl_hours
29
+ self._init_db()
30
+
31
+ def _init_db(self):
32
+ """Create cache table and indexes if not exists."""
33
+ with sqlite3.connect(self.db_path) as conn:
34
+ cursor = conn.cursor()
35
+ cursor.execute(
36
+ """
37
+ CREATE TABLE IF NOT EXISTS bfinance_cache (
38
+ cache_key TEXT PRIMARY KEY,
39
+ category TEXT NOT NULL,
40
+ payload TEXT NOT NULL,
41
+ created_at REAL NOT NULL,
42
+ expires_at REAL NOT NULL
43
+ )
44
+ """
45
+ )
46
+ cursor.execute(
47
+ "CREATE INDEX IF NOT EXISTS idx_cache_category ON bfinance_cache(category)"
48
+ )
49
+ cursor.execute(
50
+ "CREATE INDEX IF NOT EXISTS idx_cache_expires ON bfinance_cache(expires_at)"
51
+ )
52
+ conn.commit()
53
+
54
+ def get(self, cache_key: str) -> Optional[Any]:
55
+ """Retrieve unexpired cached payload."""
56
+ now = time.time()
57
+ try:
58
+ with sqlite3.connect(self.db_path) as conn:
59
+ cursor = conn.cursor()
60
+ cursor.execute(
61
+ "SELECT payload, expires_at FROM bfinance_cache WHERE cache_key = ?",
62
+ (cache_key,),
63
+ )
64
+ row = cursor.fetchone()
65
+ if row:
66
+ payload_str, expires_at = row
67
+ if expires_at > now:
68
+ return json.loads(payload_str)
69
+ else:
70
+ # Expired, clean it up
71
+ cursor.execute("DELETE FROM bfinance_cache WHERE cache_key = ?", (cache_key,))
72
+ conn.commit()
73
+ except Exception:
74
+ return None
75
+ return None
76
+
77
+ def set(
78
+ self,
79
+ cache_key: str,
80
+ payload: Any,
81
+ category: str = "general",
82
+ ttl_hours: Optional[float] = None,
83
+ ):
84
+ """Store serialized payload with TTL."""
85
+ if ttl_hours is None:
86
+ ttl_hours = self.default_ttl_hours
87
+
88
+ now = time.time()
89
+ expires_at = now + (ttl_hours * 3600.0)
90
+ payload_str = json.dumps(payload)
91
+
92
+ try:
93
+ with sqlite3.connect(self.db_path) as conn:
94
+ cursor = conn.cursor()
95
+ cursor.execute(
96
+ """
97
+ INSERT INTO bfinance_cache (cache_key, category, payload, created_at, expires_at)
98
+ VALUES (?, ?, ?, ?, ?)
99
+ ON CONFLICT(cache_key) DO UPDATE SET
100
+ payload = excluded.payload,
101
+ created_at = excluded.created_at,
102
+ expires_at = excluded.expires_at
103
+ """,
104
+ (cache_key, category, payload_str, now, expires_at),
105
+ )
106
+ conn.commit()
107
+ except Exception:
108
+ pass
109
+
110
+ def clear(self, category: Optional[str] = None):
111
+ """Clear all or category-specific cache entries."""
112
+ try:
113
+ with sqlite3.connect(self.db_path) as conn:
114
+ cursor = conn.cursor()
115
+ if category:
116
+ cursor.execute("DELETE FROM bfinance_cache WHERE category = ?", (category,))
117
+ else:
118
+ cursor.execute("DELETE FROM bfinance_cache")
119
+ conn.commit()
120
+ except Exception:
121
+ pass