PRSHNTKUMR commited on
Commit
fba572c
Β·
verified Β·
1 Parent(s): 6442295

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +750 -556
main.py CHANGED
@@ -1,557 +1,751 @@
1
- from fastapi import FastAPI, HTTPException, File, UploadFile
2
- from fastapi.responses import JSONResponse, FileResponse
3
- from fastapi.middleware.cors import CORSMiddleware
4
- from pydantic import BaseModel, ValidationError
5
- from typing import Optional, List, Dict, Any
6
- import json
7
- import matplotlib
8
- matplotlib.use('Agg') # Non-interactive backend for server
9
- import matplotlib.pyplot as plt
10
- import numpy as np
11
- import pandas as pd
12
- from datetime import datetime
13
- import io
14
- import base64
15
- from PIL import Image
16
- import seaborn as sns
17
- import uvicorn
18
- import os
19
- import tempfile
20
- from pathlib import Path
21
-
22
- # Set style for better-looking charts
23
- plt.style.use('seaborn-v0_8')
24
- sns.set_palette("husl")
25
-
26
- # Pydantic models for request/response validation
27
- class HoldingModel(BaseModel):
28
- stockName: str
29
- shares: int
30
- purchasePrice: float
31
- currentPrice: float
32
- investment: float
33
- currentValue: float
34
- profitLoss: float
35
- profitLossPercentage: float
36
- fiftyTwoWeekLow: Optional[float] = None
37
- fiftyTwoWeekHigh: Optional[float] = None
38
- positionInRange: Optional[float] = None
39
- sector: Optional[str] = "N/A"
40
-
41
- class PortfolioSummaryModel(BaseModel):
42
- totalInvested: float
43
- currentValue: float
44
- netPL: float
45
- netPLPercentage: float
46
- reportDate: str
47
-
48
- class AllocationModel(BaseModel):
49
- stock: str
50
- percentage: float
51
- value: float
52
-
53
- class InsightsModel(BaseModel):
54
- bestPerformer: Optional[Dict[str, Any]] = None
55
- underperformer: Optional[Dict[str, Any]] = None
56
-
57
- class MarketContextModel(BaseModel):
58
- niftyLevel: Optional[float] = None
59
- portfolioVsMarket: Optional[str] = None
60
- marketTrend: Optional[str] = None
61
-
62
- class PortfolioDataModel(BaseModel):
63
- portfolioSummary: PortfolioSummaryModel
64
- holdings: List[HoldingModel]
65
- allocation: Optional[List[AllocationModel]] = []
66
- insights: Optional[InsightsModel] = None
67
- marketContext: Optional[MarketContextModel] = None
68
-
69
- class PortfolioResponse(BaseModel):
70
- combined_chart: str # base64 encoded
71
- allocation_chart: str
72
- performance_chart: str
73
- comparison_chart: str
74
- text_summary: str
75
- telegram_message: str
76
- status: str = "success"
77
-
78
- # Initialize FastAPI app
79
- app = FastAPI(
80
- title="πŸ“Š Portfolio Analysis API",
81
- description="Comprehensive portfolio analysis with charts and insights",
82
- version="1.0.0",
83
- docs_url="/", # Swagger UI at root
84
- redoc_url="/docs"
85
- )
86
-
87
- # Add CORS middleware
88
- app.add_middleware(
89
- CORSMiddleware,
90
- allow_origins=["*"],
91
- allow_credentials=True,
92
- allow_methods=["*"],
93
- allow_headers=["*"],
94
- )
95
-
96
- # Create temp directory for charts
97
- TEMP_DIR = Path(tempfile.gettempdir()) / "portfolio_charts"
98
- TEMP_DIR.mkdir(exist_ok=True)
99
-
100
- class PortfolioAnalyzer:
101
- """Portfolio analysis engine"""
102
-
103
- def __init__(self):
104
- self.colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7', '#DDA0DD', '#98D8C8']
105
-
106
- def analyze_portfolio(self, portfolio_data: PortfolioDataModel) -> PortfolioResponse:
107
- """Main analysis function"""
108
- try:
109
- # Create charts
110
- charts = self._create_charts(
111
- portfolio_data.portfolioSummary,
112
- portfolio_data.holdings,
113
- portfolio_data.allocation or [],
114
- portfolio_data.marketContext or MarketContextModel()
115
- )
116
-
117
- # Create text summaries
118
- text_summary = self._create_text_summary(
119
- portfolio_data.portfolioSummary,
120
- portfolio_data.holdings,
121
- portfolio_data.insights or InsightsModel(),
122
- portfolio_data.marketContext or MarketContextModel()
123
- )
124
-
125
- telegram_msg = self._create_telegram_message(
126
- portfolio_data.portfolioSummary,
127
- portfolio_data.holdings,
128
- portfolio_data.insights or InsightsModel()
129
- )
130
-
131
- return PortfolioResponse(
132
- combined_chart=charts['combined'],
133
- allocation_chart=charts['allocation'],
134
- performance_chart=charts['performance'],
135
- comparison_chart=charts['comparison'],
136
- text_summary=text_summary,
137
- telegram_message=telegram_msg
138
- )
139
-
140
- except Exception as e:
141
- raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}")
142
-
143
- def _create_charts(self, portfolio_summary, holdings, allocation, market_context):
144
- """Create multiple chart types for portfolio visualization"""
145
-
146
- # 1. COMBINED DASHBOARD
147
- fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2, figsize=(16, 12))
148
- fig.suptitle(f'Portfolio Dashboard - {portfolio_summary.reportDate}', fontsize=16, fontweight='bold')
149
-
150
- # Portfolio overview text
151
- total_value = portfolio_summary.currentValue
152
- total_pl = portfolio_summary.netPL
153
- pl_pct = portfolio_summary.netPLPercentage
154
-
155
- overview_text = f'Total Value: β‚Ή{total_value:,.0f} | P&L: β‚Ή{total_pl:,.0f} ({pl_pct:+.2f}%) | Invested: β‚Ή{portfolio_summary.totalInvested:,.0f}'
156
- fig.text(0.5, 0.95, overview_text, ha='center', fontsize=12, bbox=dict(boxstyle="round,pad=0.5", facecolor='lightblue', alpha=0.7))
157
-
158
- # Allocation Pie Chart
159
- if allocation:
160
- stocks = [a.stock for a in allocation]
161
- percentages = [a.percentage for a in allocation]
162
- ax1.pie(percentages, labels=stocks, autopct='%1.1f%%', colors=self.colors[:len(stocks)], startangle=90)
163
- ax1.set_title('Portfolio Allocation', fontweight='bold')
164
-
165
- # P&L Bar Chart
166
- stock_names = [h.stockName.replace('.NS', '') for h in holdings]
167
- profit_loss = [h.profitLoss for h in holdings]
168
- bars = ax2.bar(stock_names, profit_loss, color=['green' if pl > 0 else 'red' for pl in profit_loss])
169
- ax2.set_title('Profit/Loss by Stock', fontweight='bold')
170
- ax2.set_ylabel('P&L (β‚Ή)')
171
- ax2.tick_params(axis='x', rotation=45)
172
-
173
- # Performance Percentage Chart
174
- perf_percentages = [h.profitLossPercentage for h in holdings]
175
- ax3.bar(stock_names, perf_percentages, color=['green' if p > 0 else 'red' for p in perf_percentages])
176
- ax3.set_title('Returns (%)', fontweight='bold')
177
- ax3.set_ylabel('Return %')
178
- ax3.tick_params(axis='x', rotation=45)
179
-
180
- # Holdings Value Chart
181
- current_values = [h.currentValue for h in holdings]
182
- investments = [h.investment for h in holdings]
183
- x = np.arange(len(stock_names))
184
- width = 0.35
185
- ax4.bar(x - width/2, investments, width, label='Invested', color='lightblue')
186
- ax4.bar(x + width/2, current_values, width, label='Current', color='darkblue')
187
- ax4.set_title('Investment vs Current Value', fontweight='bold')
188
- ax4.set_ylabel('Value (β‚Ή)')
189
- ax4.set_xticks(x)
190
- ax4.set_xticklabels(stock_names, rotation=45)
191
- ax4.legend()
192
-
193
- plt.tight_layout()
194
- combined_chart = self._save_chart_as_base64(fig)
195
- plt.close(fig)
196
-
197
- # 2. ALLOCATION CHART
198
- fig_alloc, ax_alloc = plt.subplots(figsize=(10, 8))
199
- if allocation:
200
- stocks = [a.stock for a in allocation]
201
- percentages = [a.percentage for a in allocation]
202
- wedges, texts, autotexts = ax_alloc.pie(
203
- percentages,
204
- labels=stocks,
205
- autopct='%1.1f%%',
206
- colors=self.colors[:len(stocks)],
207
- startangle=90,
208
- explode=[0.05 if p == max(percentages) else 0 for p in percentages]
209
- )
210
- ax_alloc.set_title(f'Portfolio Allocation - Total: β‚Ή{total_value:,.0f}', fontsize=16, fontweight='bold')
211
- allocation_chart = self._save_chart_as_base64(fig_alloc)
212
- plt.close(fig_alloc)
213
-
214
- # 3. PERFORMANCE ANALYSIS
215
- fig_perf, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2, figsize=(14, 10))
216
-
217
- # P&L Bar Chart
218
- ax1.bar(stock_names, profit_loss, color=['#2ECC71' if pl > 0 else '#E74C3C' for pl in profit_loss])
219
- ax1.set_title('Profit/Loss (β‚Ή)', fontweight='bold')
220
- ax1.tick_params(axis='x', rotation=45)
221
-
222
- # Percentage Returns
223
- ax2.bar(stock_names, perf_percentages, color=['#2ECC71' if p > 0 else '#E74C3C' for p in perf_percentages])
224
- ax2.set_title('Returns (%)', fontweight='bold')
225
- ax2.tick_params(axis='x', rotation=45)
226
-
227
- # Investment vs Current Value
228
- x = np.arange(len(stock_names))
229
- width = 0.35
230
- ax3.bar(x - width/2, investments, width, label='Invested', color='#3498DB', alpha=0.7)
231
- ax3.bar(x + width/2, current_values, width, label='Current', color='#9B59B6', alpha=0.7)
232
- ax3.set_title('Investment vs Current Value', fontweight='bold')
233
- ax3.set_xticks(x)
234
- ax3.set_xticklabels(stock_names, rotation=45)
235
- ax3.legend()
236
-
237
- # Risk Analysis (52-week position)
238
- positions = [h.positionInRange or 50 for h in holdings]
239
- colors_risk = ['#E74C3C' if p < 20 else '#F39C12' if p < 60 else '#2ECC71' for p in positions]
240
- ax4.bar(stock_names, positions, color=colors_risk)
241
- ax4.set_title('52-Week Range Position (%)', fontweight='bold')
242
- ax4.tick_params(axis='x', rotation=45)
243
- ax4.axhline(y=20, color='red', linestyle='--', alpha=0.5)
244
- ax4.axhline(y=80, color='green', linestyle='--', alpha=0.5)
245
-
246
- plt.tight_layout()
247
- performance_chart = self._save_chart_as_base64(fig_perf)
248
- plt.close(fig_perf)
249
-
250
- # 4. COMPARISON CHART
251
- fig_comp, ax_comp = plt.subplots(figsize=(12, 6))
252
-
253
- metrics = ['Portfolio Return', 'Best Stock', 'Worst Stock', 'Market (Nifty)']
254
- values = [
255
- pl_pct,
256
- max(perf_percentages) if perf_percentages else 0,
257
- min(perf_percentages) if perf_percentages else 0,
258
- -0.95
259
- ]
260
-
261
- colors_comp = ['#3498DB', '#2ECC71', '#E74C3C', '#95A5A6']
262
- bars_comp = ax_comp.bar(metrics, values, color=colors_comp)
263
-
264
- ax_comp.set_title('Performance Comparison', fontsize=16, fontweight='bold')
265
- ax_comp.set_ylabel('Return (%)')
266
- ax_comp.axhline(y=0, color='black', linestyle='-', alpha=0.3)
267
-
268
- # Add value labels
269
- for bar, value in zip(bars_comp, values):
270
- height = bar.get_height()
271
- ax_comp.text(bar.get_x() + bar.get_width()/2., height,
272
- f'{value:+.2f}%', ha='center', va='bottom' if height > 0 else 'top')
273
-
274
- plt.xticks(rotation=45)
275
- plt.tight_layout()
276
- comparison_chart = self._save_chart_as_base64(fig_comp)
277
- plt.close(fig_comp)
278
-
279
- return {
280
- 'combined': combined_chart,
281
- 'allocation': allocation_chart,
282
- 'performance': performance_chart,
283
- 'comparison': comparison_chart
284
- }
285
-
286
- def _save_chart_as_base64(self, fig):
287
- """Convert matplotlib figure to base64 string"""
288
- buf = io.BytesIO()
289
- fig.savefig(buf, format='png', dpi=300, bbox_inches='tight')
290
- buf.seek(0)
291
- image_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
292
- buf.close()
293
- return image_base64
294
-
295
- def _create_text_summary(self, portfolio_summary, holdings, insights, market_context):
296
- """Create detailed text summary"""
297
-
298
- total_stocks = len(holdings)
299
- profitable_stocks = len([h for h in holdings if h.profitLoss > 0])
300
- losing_stocks = len([h for h in holdings if h.profitLoss < 0])
301
-
302
- top_gainer = max(holdings, key=lambda x: x.profitLossPercentage) if holdings else None
303
- top_loser = min(holdings, key=lambda x: x.profitLossPercentage) if holdings else None
304
-
305
- stocks_near_low = len([h for h in holdings if (h.positionInRange or 50) < 20])
306
-
307
- summary = f"""
308
- πŸ“Š PORTFOLIO ANALYSIS REPORT
309
- πŸ“… Date: {portfolio_summary.reportDate}
310
- ═══════════════════════════════════════
311
- πŸ’° FINANCIAL OVERVIEW
312
- ━━━━━━━━━━━━━━━━━━━━━━
313
- β€’ Total Investment: β‚Ή{portfolio_summary.totalInvested:,.2f}
314
- β€’ Current Value: β‚Ή{portfolio_summary.currentValue:,.2f}
315
- β€’ Net P&L: β‚Ή{portfolio_summary.netPL:,.2f} ({portfolio_summary.netPLPercentage:+.2f}%)
316
- β€’ Portfolio Status: {'🟒 Profitable' if portfolio_summary.netPL > 0 else 'πŸ”΄ Loss' if portfolio_summary.netPL < -1000 else '🟑 Neutral'}
317
-
318
- πŸ“ˆ STOCK PERFORMANCE
319
- ━━━━━━━━━━━━━━━━━━━━━━
320
- β€’ Total Stocks: {total_stocks}
321
- β€’ Profitable: {profitable_stocks} | Losing: {losing_stocks}
322
- β€’ Best Performer: {top_gainer.stockName.replace('.NS', '') if top_gainer else 'N/A'} ({top_gainer.profitLossPercentage:+.2f}% if top_gainer else 0)
323
- β€’ Worst Performer: {top_loser.stockName.replace('.NS', '') if top_loser else 'N/A'} ({top_loser.profitLossPercentage:.2f}% if top_loser else 0)
324
-
325
- πŸ“Š INDIVIDUAL HOLDINGS
326
- ━━━━━━━━━━━━━━━━━━━━━━"""
327
-
328
- for holding in holdings:
329
- status_emoji = "🟒" if holding.profitLoss > 0 else "πŸ”΄" if holding.profitLoss < 0 else "🟑"
330
- summary += f"""
331
- {status_emoji} {holding.stockName.replace('.NS', '')}:
332
- β€’ Shares: {holding.shares} | Price: β‚Ή{holding.currentPrice:.2f}
333
- β€’ Investment: β‚Ή{holding.investment:,.0f} β†’ Current: β‚Ή{holding.currentValue:,.0f}
334
- β€’ P&L: β‚Ή{holding.profitLoss:,.0f} ({holding.profitLossPercentage:+.2f}%)
335
- β€’ Sector: {holding.sector}"""
336
-
337
- summary += f"""
338
-
339
- ⚠️ RISK ANALYSIS
340
- ━━━━━━━━━━━━━━━━━━━━━━
341
- β€’ Stocks near 52W low: {stocks_near_low}
342
- β€’ Risk Level: {'πŸ”΄ HIGH' if stocks_near_low > 2 else '🟑 MEDIUM' if stocks_near_low > 0 else '🟒 LOW'}
343
-
344
- πŸ“Š MARKET CONTEXT
345
- ━━━━━━━━━━━━━━━━━━━━━━
346
- β€’ Nifty 50: {market_context.niftyLevel or 'N/A'}
347
- β€’ Portfolio vs Market: {market_context.portfolioVsMarket or 'N/A'}
348
- β€’ Market Trend: {market_context.marketTrend or 'N/A'}
349
-
350
- πŸ’‘ INSIGHTS & RECOMMENDATIONS
351
- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"""
352
-
353
- if portfolio_summary.netPLPercentage > 5:
354
- summary += "\nβ€’ 🎯 Strong portfolio performance - consider partial profit booking"
355
- elif portfolio_summary.netPLPercentage < -5:
356
- summary += "\nβ€’ ⚠️ Portfolio underperforming - review individual positions"
357
-
358
- if stocks_near_low > 1:
359
- summary += f"\nβ€’ πŸ“‰ {stocks_near_low} stocks near 52W lows - monitor closely"
360
-
361
- if profitable_stocks > losing_stocks:
362
- summary += "\nβ€’ βœ… More winners than losers - good stock selection"
363
-
364
- summary += f"""
365
-
366
- πŸ”Έ Report generated at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
367
- πŸ”Έ Disclaimer: This is for educational purposes only, not financial advice.
368
- """
369
-
370
- return summary
371
-
372
- def _create_telegram_message(self, portfolio_summary, holdings, insights):
373
- """Create Telegram-optimized message"""
374
-
375
- top_gainer = max(holdings, key=lambda x: x.profitLossPercentage) if holdings else None
376
- top_loser = min(holdings, key=lambda x: x.profitLossPercentage) if holdings else None
377
-
378
- status_emoji = "πŸ“ˆ" if portfolio_summary.netPL > 0 else "πŸ“‰" if portfolio_summary.netPL < -1000 else "➑️"
379
-
380
- telegram_msg = f"""🏦 PORTFOLIO UPDATE
381
- πŸ“… {portfolio_summary.reportDate}
382
-
383
- {status_emoji} PERFORMANCE
384
- πŸ’° Value: β‚Ή{portfolio_summary.currentValue:,.0f}
385
- πŸ“Š P&L: {'+' if portfolio_summary.netPL > 0 else ''}β‚Ή{portfolio_summary.netPL:,.0f} ({portfolio_summary.netPLPercentage:+.2f}%)
386
-
387
- πŸ† TOP MOVES
388
- πŸ“ˆ Best: {top_gainer.stockName.replace('.NS', '') if top_gainer else 'N/A'} {top_gainer.profitLossPercentage:+.1f}% if top_gainer else 0
389
- πŸ“‰ Worst: {top_loser.stockName.replace('.NS', '') if top_loser else 'N/A'} {top_loser.profitLossPercentage:.1f}% if top_loser else 0
390
-
391
- πŸ“Š HOLDINGS"""
392
-
393
- for holding in holdings[:5]: # Top 5 holdings
394
- emoji = "🟒" if holding.profitLoss > 0 else "πŸ”΄"
395
- telegram_msg += f"\n{emoji} {holding.stockName.replace('.NS', '')}: {holding.profitLossPercentage:+.1f}%"
396
-
397
- telegram_msg += f"\n\nπŸ’‘ Status: {len([h for h in holdings if h.profitLoss > 0])}/{len(holdings)} profitable"
398
-
399
- return telegram_msg
400
-
401
- # Initialize analyzer
402
- analyzer = PortfolioAnalyzer()
403
-
404
- @app.get("/", response_class=JSONResponse)
405
- async def root():
406
- """API Health Check"""
407
- return {
408
- "message": "Portfolio Analysis API is running!",
409
- "status": "healthy",
410
- "endpoints": {
411
- "/analyze": "POST - Analyze portfolio data",
412
- "/analyze-file": "POST - Upload JSON file for analysis",
413
- "/sample": "GET - Get sample portfolio data",
414
- "/docs": "GET - API documentation"
415
- }
416
- }
417
-
418
- @app.post("/analyze", response_model=PortfolioResponse)
419
- async def analyze_portfolio(portfolio_data: PortfolioDataModel):
420
- """
421
- Analyze portfolio data and generate comprehensive reports
422
-
423
- - **portfolio_data**: Complete portfolio data including summary, holdings, allocation
424
- - Returns: Charts (base64), text summary, and Telegram message
425
- """
426
- try:
427
- result = analyzer.analyze_portfolio(portfolio_data)
428
- return result
429
- except ValidationError as e:
430
- raise HTTPException(status_code=422, detail=f"Invalid data format: {e}")
431
- except Exception as e:
432
- raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}")
433
-
434
- @app.post("/analyze-file")
435
- async def analyze_portfolio_file(file: UploadFile = File(...)):
436
- """
437
- Upload JSON file and analyze portfolio
438
-
439
- - **file**: JSON file containing portfolio data
440
- - Returns: Charts (base64), text summary, and Telegram message
441
- """
442
- try:
443
- # Validate file type
444
- if not file.filename.endswith('.json'):
445
- raise HTTPException(status_code=400, detail="Please upload a JSON file")
446
-
447
- # Read and parse JSON
448
- content = await file.read()
449
- portfolio_data = json.loads(content.decode('utf-8'))
450
-
451
- # Validate using Pydantic model
452
- validated_data = PortfolioDataModel(**portfolio_data)
453
-
454
- # Analyze portfolio
455
- result = analyzer.analyze_portfolio(validated_data)
456
- return result
457
-
458
- except json.JSONDecodeError:
459
- raise HTTPException(status_code=400, detail="Invalid JSON format")
460
- except ValidationError as e:
461
- raise HTTPException(status_code=422, detail=f"Invalid data structure: {e}")
462
- except Exception as e:
463
- raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}")
464
-
465
- @app.get("/sample")
466
- async def get_sample_data():
467
- """Get sample portfolio data for testing"""
468
- sample_data = {
469
- "portfolioSummary": {
470
- "totalInvested": 213600,
471
- "currentValue": 214327.5,
472
- "netPL": 727.5,
473
- "netPLPercentage": 0.34,
474
- "reportDate": "September 27, 2025"
475
- },
476
- "holdings": [
477
- {
478
- "stockName": "RELIANCE.NS",
479
- "shares": 20,
480
- "purchasePrice": 1400,
481
- "currentPrice": 1375,
482
- "investment": 28000,
483
- "currentValue": 27500,
484
- "profitLoss": -500,
485
- "profitLossPercentage": -1.79,
486
- "fiftyTwoWeekLow": 1114.85,
487
- "fiftyTwoWeekHigh": 1551,
488
- "positionInRange": 59.5,
489
- "sector": "Energy"
490
- },
491
- {
492
- "stockName": "HDFCBANK.NS",
493
- "shares": 22,
494
- "purchasePrice": 800,
495
- "currentPrice": 950,
496
- "investment": 17600,
497
- "currentValue": 20900,
498
- "profitLoss": 3300,
499
- "profitLossPercentage": 18.75,
500
- "fiftyTwoWeekLow": 806.5,
501
- "fiftyTwoWeekHigh": 1018.85,
502
- "positionInRange": 67.6,
503
- "sector": "Banking"
504
- },
505
- {
506
- "stockName": "ITC.NS",
507
- "shares": 30,
508
- "purchasePrice": 500,
509
- "currentPrice": 403,
510
- "investment": 15000,
511
- "currentValue": 12090,
512
- "profitLoss": -2910,
513
- "profitLossPercentage": -19.4,
514
- "fiftyTwoWeekLow": 390.15,
515
- "fiftyTwoWeekHigh": 528.5,
516
- "positionInRange": 9.3,
517
- "sector": "FMCG"
518
- }
519
- ],
520
- "allocation": [
521
- {"stock": "HDFC", "percentage": 30.4, "value": 20900},
522
- {"stock": "RIL", "percentage": 40.0, "value": 27500},
523
- {"stock": "ITC", "percentage": 29.6, "value": 12090}
524
- ],
525
- "insights": {
526
- "bestPerformer": {"stock": "HDFC", "returnPercentage": 18.75},
527
- "underperformer": {"stock": "ITC", "returnPercentage": -19.4}
528
- },
529
- "marketContext": {
530
- "niftyLevel": 24654.7,
531
- "portfolioVsMarket": "Slightly outperforming with +0.34% overall return",
532
- "marketTrend": "Cautious amid global uncertainties"
533
- }
534
- }
535
- return sample_data
536
-
537
- @app.get("/health")
538
- async def health_check():
539
- """Detailed health check"""
540
- return {
541
- "status": "healthy",
542
- "timestamp": datetime.now().isoformat(),
543
- "version": "1.0.0",
544
- "dependencies": {
545
- "matplotlib": "available",
546
- "pandas": "available",
547
- "numpy": "available"
548
- }
549
- }
550
-
551
- if __name__ == "__main__":
552
- uvicorn.run(
553
- "main:app",
554
- host="0.0.0.0",
555
- port=int(os.getenv("PORT", 7860)), # HuggingFace default port
556
- reload=False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
557
  )
 
1
+ from fastapi import FastAPI, HTTPException, File, UploadFile
2
+ from fastapi.responses import JSONResponse, FileResponse
3
+ from fastapi.middleware.cors import CORSMiddleware
4
+ from pydantic import BaseModel, ValidationError, Field
5
+ from typing import Optional, List, Dict, Any, Union
6
+ import json
7
+ import matplotlib
8
+ matplotlib.use('Agg') # Non-interactive backend for server
9
+ import matplotlib.pyplot as plt
10
+ import numpy as np
11
+ import pandas as pd
12
+ from datetime import datetime
13
+ import io
14
+ import base64
15
+ from PIL import Image
16
+ import seaborn as sns
17
+ import uvicorn
18
+ import os
19
+ import tempfile
20
+ from pathlib import Path
21
+
22
+ # Set style for better-looking charts
23
+ plt.style.use('seaborn-v0_8')
24
+ sns.set_palette("husl")
25
+
26
+ # Enhanced Pydantic models to handle extended data structure
27
+ class ReportMetadataModel(BaseModel):
28
+ generatedDate: Optional[str] = None
29
+ reportDate: Optional[str] = None
30
+ status: Optional[str] = None
31
+ dataSource: Optional[str] = None
32
+
33
+ class HoldingModel(BaseModel):
34
+ stockName: str
35
+ shares: int
36
+ purchasePrice: float
37
+ currentPrice: float
38
+ investment: float
39
+ currentValue: float
40
+ profitLoss: float
41
+ profitLossPercentage: float
42
+ fiftyTwoWeekLow: Optional[float] = None
43
+ fiftyTwoWeekHigh: Optional[float] = None
44
+ positionInRange: Optional[float] = None
45
+ sector: Optional[str] = "N/A"
46
+ volume: Optional[str] = None
47
+ educationalNote: Optional[str] = None
48
+ lastUpdated: Optional[str] = None
49
+ reportId: Optional[str] = None
50
+
51
+ class PortfolioSummaryModel(BaseModel):
52
+ totalInvested: float
53
+ currentValue: float
54
+ netPL: float
55
+ netPLPercentage: float
56
+ reportDate: str
57
+
58
+ class AllocationModel(BaseModel):
59
+ stock: str
60
+ percentage: float
61
+ value: float
62
+
63
+ class PerformerModel(BaseModel):
64
+ stock: str
65
+ returnPercentage: float
66
+
67
+ class InsightsModel(BaseModel):
68
+ bestPerformer: Optional[PerformerModel] = None
69
+ underperformer: Optional[PerformerModel] = None
70
+
71
+ class MarketContextModel(BaseModel):
72
+ niftyLevel: Optional[float] = None
73
+ portfolioVsMarket: Optional[str] = None
74
+ marketTrend: Optional[str] = None
75
+
76
+ class EducationalContentModel(BaseModel):
77
+ observations: Optional[List[str]] = []
78
+ disclaimer: Optional[str] = None
79
+
80
+ class DebugModel(BaseModel):
81
+ totalStocksFound: Optional[int] = None
82
+ profitLossValues: Optional[List[Dict[str, Any]]] = []
83
+ totalLinesProcessed: Optional[int] = None
84
+
85
+ class FlexiblePortfolioDataModel(BaseModel):
86
+ # Core required fields
87
+ portfolioSummary: PortfolioSummaryModel
88
+ holdings: List[HoldingModel]
89
+
90
+ # Optional extended fields
91
+ allocation: Optional[List[AllocationModel]] = []
92
+ insights: Optional[InsightsModel] = None
93
+ marketContext: Optional[MarketContextModel] = None
94
+
95
+ # New extended fields
96
+ reportMetadata: Optional[ReportMetadataModel] = None
97
+ stockRecords: Optional[List[HoldingModel]] = []
98
+ educationalContent: Optional[EducationalContentModel] = None
99
+ debug: Optional[DebugModel] = None
100
+
101
+ # Handle raw data fields
102
+ data: Optional[List[str]] = []
103
+ output: Optional[str] = None
104
+
105
+ class PortfolioResponse(BaseModel):
106
+ combined_chart: str # base64 encoded
107
+ allocation_chart: str
108
+ performance_chart: str
109
+ comparison_chart: str
110
+ text_summary: str
111
+ telegram_message: str
112
+ status: str = "success"
113
+
114
+ # Initialize FastAPI app
115
+ app = FastAPI(
116
+ title="πŸ“Š Portfolio Analysis API - Enhanced",
117
+ description="Enhanced portfolio analysis with flexible data handling",
118
+ version="2.0.0",
119
+ docs_url="/", # Swagger UI at root
120
+ redoc_url="/docs"
121
+ )
122
+
123
+ # Add CORS middleware
124
+ app.add_middleware(
125
+ CORSMiddleware,
126
+ allow_origins=["*"],
127
+ allow_credentials=True,
128
+ allow_methods=["*"],
129
+ allow_headers=["*"],
130
+ )
131
+
132
+ # Create temp directory for charts
133
+ TEMP_DIR = Path(tempfile.gettempdir()) / "portfolio_charts"
134
+ TEMP_DIR.mkdir(exist_ok=True)
135
+
136
+ class PortfolioAnalyzer:
137
+ """Enhanced Portfolio analysis engine"""
138
+
139
+ def __init__(self):
140
+ self.colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7', '#DDA0DD', '#98D8C8']
141
+
142
+ def preprocess_data(self, raw_data: FlexiblePortfolioDataModel) -> Dict[str, Any]:
143
+ """Preprocess and normalize input data"""
144
+
145
+ # If we have stringified JSON in data or output fields, try to parse it
146
+ if raw_data.data and len(raw_data.data) > 0:
147
+ try:
148
+ parsed_data = json.loads(raw_data.data[0])
149
+ if isinstance(parsed_data, dict):
150
+ # Merge parsed data with existing data
151
+ return self._merge_data_sources(raw_data, parsed_data)
152
+ except (json.JSONDecodeError, IndexError):
153
+ pass
154
+
155
+ if raw_data.output:
156
+ try:
157
+ parsed_data = json.loads(raw_data.output)
158
+ if isinstance(parsed_data, dict):
159
+ return self._merge_data_sources(raw_data, parsed_data)
160
+ except json.JSONDecodeError:
161
+ pass
162
+
163
+ # Use the direct data structure
164
+ return {
165
+ 'portfolioSummary': raw_data.portfolioSummary,
166
+ 'holdings': raw_data.holdings,
167
+ 'allocation': raw_data.allocation or [],
168
+ 'insights': raw_data.insights,
169
+ 'marketContext': raw_data.marketContext,
170
+ 'reportMetadata': raw_data.reportMetadata
171
+ }
172
+
173
+ def _merge_data_sources(self, raw_data: FlexiblePortfolioDataModel, parsed_data: Dict) -> Dict[str, Any]:
174
+ """Merge data from different sources"""
175
+ result = {
176
+ 'portfolioSummary': raw_data.portfolioSummary,
177
+ 'holdings': raw_data.holdings,
178
+ 'allocation': raw_data.allocation or [],
179
+ 'insights': raw_data.insights,
180
+ 'marketContext': raw_data.marketContext,
181
+ }
182
+
183
+ # Override with parsed data if available
184
+ if 'portfolioSummary' in parsed_data:
185
+ result['portfolioSummary'] = parsed_data['portfolioSummary']
186
+ if 'holdings' in parsed_data:
187
+ result['holdings'] = parsed_data['holdings']
188
+ if 'allocation' in parsed_data:
189
+ result['allocation'] = parsed_data['allocation']
190
+ if 'insights' in parsed_data:
191
+ result['insights'] = parsed_data['insights']
192
+ if 'marketContext' in parsed_data:
193
+ result['marketContext'] = parsed_data['marketContext']
194
+
195
+ return result
196
+
197
+ def analyze_portfolio(self, portfolio_data: FlexiblePortfolioDataModel) -> PortfolioResponse:
198
+ """Main analysis function with flexible input handling"""
199
+ try:
200
+ # Preprocess the data
201
+ processed_data = self.preprocess_data(portfolio_data)
202
+
203
+ # Extract components
204
+ portfolio_summary = processed_data['portfolioSummary']
205
+ holdings = processed_data['holdings']
206
+ allocation = processed_data.get('allocation', [])
207
+ insights = processed_data.get('insights')
208
+ market_context = processed_data.get('marketContext')
209
+
210
+ # If allocation is empty, generate it from holdings
211
+ if not allocation and holdings:
212
+ allocation = self._generate_allocation_from_holdings(holdings)
213
+
214
+ # Create charts
215
+ charts = self._create_charts(
216
+ portfolio_summary,
217
+ holdings,
218
+ allocation,
219
+ market_context or {}
220
+ )
221
+
222
+ # Create text summaries
223
+ text_summary = self._create_text_summary(
224
+ portfolio_summary,
225
+ holdings,
226
+ insights or {},
227
+ market_context or {}
228
+ )
229
+
230
+ telegram_msg = self._create_telegram_message(
231
+ portfolio_summary,
232
+ holdings,
233
+ insights or {}
234
+ )
235
+
236
+ return PortfolioResponse(
237
+ combined_chart=charts['combined'],
238
+ allocation_chart=charts['allocation'],
239
+ performance_chart=charts['performance'],
240
+ comparison_chart=charts['comparison'],
241
+ text_summary=text_summary,
242
+ telegram_message=telegram_msg
243
+ )
244
+
245
+ except Exception as e:
246
+ raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}")
247
+
248
+ def _generate_allocation_from_holdings(self, holdings: List) -> List[Dict]:
249
+ """Generate allocation data from holdings if not provided"""
250
+ total_value = sum(h.get('currentValue', 0) if isinstance(h, dict) else h.currentValue for h in holdings)
251
+
252
+ allocation = []
253
+ for holding in holdings:
254
+ if isinstance(holding, dict):
255
+ stock_name = holding.get('stockName', '').replace('.NS', '')
256
+ current_value = holding.get('currentValue', 0)
257
+ else:
258
+ stock_name = holding.stockName.replace('.NS', '')
259
+ current_value = holding.currentValue
260
+
261
+ if total_value > 0:
262
+ percentage = (current_value / total_value) * 100
263
+ allocation.append({
264
+ 'stock': stock_name,
265
+ 'percentage': percentage,
266
+ 'value': current_value
267
+ })
268
+
269
+ return allocation
270
+
271
+ def _create_charts(self, portfolio_summary, holdings, allocation, market_context):
272
+ """Create multiple chart types for portfolio visualization"""
273
+
274
+ # Convert to dict format if needed
275
+ if hasattr(portfolio_summary, 'dict'):
276
+ portfolio_summary = portfolio_summary.dict()
277
+ if hasattr(market_context, 'dict'):
278
+ market_context = market_context.dict()
279
+
280
+ # Process holdings
281
+ processed_holdings = []
282
+ for h in holdings:
283
+ if hasattr(h, 'dict'):
284
+ processed_holdings.append(h.dict())
285
+ else:
286
+ processed_holdings.append(h)
287
+ holdings = processed_holdings
288
+
289
+ # Process allocation
290
+ processed_allocation = []
291
+ for a in allocation:
292
+ if hasattr(a, 'dict'):
293
+ processed_allocation.append(a.dict())
294
+ else:
295
+ processed_allocation.append(a)
296
+ allocation = processed_allocation
297
+
298
+ # 1. COMBINED DASHBOARD
299
+ fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2, figsize=(16, 12))
300
+ fig.suptitle(f'Portfolio Dashboard - {portfolio_summary.get("reportDate", "Unknown Date")}',
301
+ fontsize=16, fontweight='bold')
302
+
303
+ # Portfolio overview text
304
+ total_value = portfolio_summary.get('currentValue', 0)
305
+ total_pl = portfolio_summary.get('netPL', 0)
306
+ pl_pct = portfolio_summary.get('netPLPercentage', 0)
307
+ total_invested = portfolio_summary.get('totalInvested', 0)
308
+
309
+ overview_text = f'Total Value: β‚Ή{total_value:,.0f} | P&L: β‚Ή{total_pl:,.0f} ({pl_pct:+.2f}%) | Invested: β‚Ή{total_invested:,.0f}'
310
+ fig.text(0.5, 0.95, overview_text, ha='center', fontsize=12,
311
+ bbox=dict(boxstyle="round,pad=0.5", facecolor='lightblue', alpha=0.7))
312
+
313
+ # Allocation Pie Chart
314
+ if allocation:
315
+ stocks = [a.get('stock', 'Unknown') for a in allocation]
316
+ percentages = [a.get('percentage', 0) for a in allocation]
317
+ # Filter out zero percentages
318
+ filtered_data = [(s, p) for s, p in zip(stocks, percentages) if p > 0]
319
+ if filtered_data:
320
+ stocks, percentages = zip(*filtered_data)
321
+ ax1.pie(percentages, labels=stocks, autopct='%1.1f%%',
322
+ colors=self.colors[:len(stocks)], startangle=90)
323
+ ax1.set_title('Portfolio Allocation', fontweight='bold')
324
+
325
+ # P&L Bar Chart
326
+ stock_names = [h.get('stockName', 'Unknown').replace('.NS', '') for h in holdings]
327
+ profit_loss = [h.get('profitLoss', 0) for h in holdings]
328
+ bars = ax2.bar(stock_names, profit_loss,
329
+ color=['green' if pl > 0 else 'red' for pl in profit_loss])
330
+ ax2.set_title('Profit/Loss by Stock', fontweight='bold')
331
+ ax2.set_ylabel('P&L (β‚Ή)')
332
+ ax2.tick_params(axis='x', rotation=45)
333
+
334
+ # Performance Percentage Chart
335
+ perf_percentages = [h.get('profitLossPercentage', 0) for h in holdings]
336
+ ax3.bar(stock_names, perf_percentages,
337
+ color=['green' if p > 0 else 'red' for p in perf_percentages])
338
+ ax3.set_title('Returns (%)', fontweight='bold')
339
+ ax3.set_ylabel('Return %')
340
+ ax3.tick_params(axis='x', rotation=45)
341
+
342
+ # Holdings Value Chart
343
+ current_values = [h.get('currentValue', 0) for h in holdings]
344
+ investments = [h.get('investment', 0) for h in holdings]
345
+ x = np.arange(len(stock_names))
346
+ width = 0.35
347
+ ax4.bar(x - width/2, investments, width, label='Invested', color='lightblue')
348
+ ax4.bar(x + width/2, current_values, width, label='Current', color='darkblue')
349
+ ax4.set_title('Investment vs Current Value', fontweight='bold')
350
+ ax4.set_ylabel('Value (β‚Ή)')
351
+ ax4.set_xticks(x)
352
+ ax4.set_xticklabels(stock_names, rotation=45)
353
+ ax4.legend()
354
+
355
+ plt.tight_layout()
356
+ combined_chart = self._save_chart_as_base64(fig)
357
+ plt.close(fig)
358
+
359
+ # 2. ALLOCATION CHART
360
+ fig_alloc, ax_alloc = plt.subplots(figsize=(10, 8))
361
+ if allocation:
362
+ filtered_data = [(a.get('stock', 'Unknown'), a.get('percentage', 0))
363
+ for a in allocation if a.get('percentage', 0) > 0]
364
+ if filtered_data:
365
+ stocks, percentages = zip(*filtered_data)
366
+ wedges, texts, autotexts = ax_alloc.pie(
367
+ percentages,
368
+ labels=stocks,
369
+ autopct='%1.1f%%',
370
+ colors=self.colors[:len(stocks)],
371
+ startangle=90,
372
+ explode=[0.05 if p == max(percentages) else 0 for p in percentages]
373
+ )
374
+ ax_alloc.set_title(f'Portfolio Allocation - Total: β‚Ή{total_value:,.0f}',
375
+ fontsize=16, fontweight='bold')
376
+ allocation_chart = self._save_chart_as_base64(fig_alloc)
377
+ plt.close(fig_alloc)
378
+
379
+ # 3. PERFORMANCE ANALYSIS
380
+ fig_perf, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2, figsize=(14, 10))
381
+
382
+ # P&L Bar Chart
383
+ ax1.bar(stock_names, profit_loss,
384
+ color=['#2ECC71' if pl > 0 else '#E74C3C' for pl in profit_loss])
385
+ ax1.set_title('Profit/Loss (β‚Ή)', fontweight='bold')
386
+ ax1.tick_params(axis='x', rotation=45)
387
+
388
+ # Percentage Returns
389
+ ax2.bar(stock_names, perf_percentages,
390
+ color=['#2ECC71' if p > 0 else '#E74C3C' for p in perf_percentages])
391
+ ax2.set_title('Returns (%)', fontweight='bold')
392
+ ax2.tick_params(axis='x', rotation=45)
393
+
394
+ # Investment vs Current Value
395
+ x = np.arange(len(stock_names))
396
+ width = 0.35
397
+ ax3.bar(x - width/2, investments, width, label='Invested', color='#3498DB', alpha=0.7)
398
+ ax3.bar(x + width/2, current_values, width, label='Current', color='#9B59B6', alpha=0.7)
399
+ ax3.set_title('Investment vs Current Value', fontweight='bold')
400
+ ax3.set_xticks(x)
401
+ ax3.set_xticklabels(stock_names, rotation=45)
402
+ ax3.legend()
403
+
404
+ # Risk Analysis (52-week position)
405
+ positions = [h.get('positionInRange', 50) for h in holdings]
406
+ colors_risk = ['#E74C3C' if p < 20 else '#F39C12' if p < 60 else '#2ECC71' for p in positions]
407
+ ax4.bar(stock_names, positions, color=colors_risk)
408
+ ax4.set_title('52-Week Range Position (%)', fontweight='bold')
409
+ ax4.tick_params(axis='x', rotation=45)
410
+ ax4.axhline(y=20, color='red', linestyle='--', alpha=0.5)
411
+ ax4.axhline(y=80, color='green', linestyle='--', alpha=0.5)
412
+
413
+ plt.tight_layout()
414
+ performance_chart = self._save_chart_as_base64(fig_perf)
415
+ plt.close(fig_perf)
416
+
417
+ # 4. COMPARISON CHART
418
+ fig_comp, ax_comp = plt.subplots(figsize=(12, 6))
419
+
420
+ metrics = ['Portfolio Return', 'Best Stock', 'Worst Stock', 'Market (Nifty)']
421
+ values = [
422
+ pl_pct,
423
+ max(perf_percentages) if perf_percentages else 0,
424
+ min(perf_percentages) if perf_percentages else 0,
425
+ -0.95
426
+ ]
427
+
428
+ colors_comp = ['#3498DB', '#2ECC71', '#E74C3C', '#95A5A6']
429
+ bars_comp = ax_comp.bar(metrics, values, color=colors_comp)
430
+
431
+ ax_comp.set_title('Performance Comparison', fontsize=16, fontweight='bold')
432
+ ax_comp.set_ylabel('Return (%)')
433
+ ax_comp.axhline(y=0, color='black', linestyle='-', alpha=0.3)
434
+
435
+ # Add value labels
436
+ for bar, value in zip(bars_comp, values):
437
+ height = bar.get_height()
438
+ ax_comp.text(bar.get_x() + bar.get_width()/2., height,
439
+ f'{value:+.2f}%', ha='center', va='bottom' if height > 0 else 'top')
440
+
441
+ plt.xticks(rotation=45)
442
+ plt.tight_layout()
443
+ comparison_chart = self._save_chart_as_base64(fig_comp)
444
+ plt.close(fig_comp)
445
+
446
+ return {
447
+ 'combined': combined_chart,
448
+ 'allocation': allocation_chart,
449
+ 'performance': performance_chart,
450
+ 'comparison': comparison_chart
451
+ }
452
+
453
+ def _save_chart_as_base64(self, fig):
454
+ """Convert matplotlib figure to base64 string"""
455
+ buf = io.BytesIO()
456
+ fig.savefig(buf, format='png', dpi=300, bbox_inches='tight')
457
+ buf.seek(0)
458
+ image_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
459
+ buf.close()
460
+ return image_base64
461
+
462
+ def _create_text_summary(self, portfolio_summary, holdings, insights, market_context):
463
+ """Create detailed text summary with flexible data handling"""
464
+
465
+ # Handle different data formats
466
+ if hasattr(portfolio_summary, 'dict'):
467
+ portfolio_summary = portfolio_summary.dict()
468
+ if hasattr(insights, 'dict'):
469
+ insights = insights.dict()
470
+ if hasattr(market_context, 'dict'):
471
+ market_context = market_context.dict()
472
+
473
+ # Process holdings
474
+ processed_holdings = []
475
+ for h in holdings:
476
+ if hasattr(h, 'dict'):
477
+ processed_holdings.append(h.dict())
478
+ else:
479
+ processed_holdings.append(h)
480
+ holdings = processed_holdings
481
+
482
+ total_stocks = len(holdings)
483
+ profitable_stocks = len([h for h in holdings if h.get('profitLoss', 0) > 0])
484
+ losing_stocks = len([h for h in holdings if h.get('profitLoss', 0) < 0])
485
+
486
+ # Find top performers
487
+ perf_percentages = [h.get('profitLossPercentage', 0) for h in holdings]
488
+ if perf_percentages:
489
+ max_idx = perf_percentages.index(max(perf_percentages))
490
+ min_idx = perf_percentages.index(min(perf_percentages))
491
+ top_gainer = holdings[max_idx]
492
+ top_loser = holdings[min_idx]
493
+ else:
494
+ top_gainer = {}
495
+ top_loser = {}
496
+
497
+ stocks_near_low = len([h for h in holdings if h.get('positionInRange', 50) < 20])
498
+
499
+ summary = f"""
500
+ πŸ“Š PORTFOLIO ANALYSIS REPORT (Enhanced)
501
+ πŸ“… Date: {portfolio_summary.get('reportDate', 'Unknown')}
502
+ ═══════════════════════════════════════
503
+ πŸ’° FINANCIAL OVERVIEW
504
+ ━━━━━━━━━━━━━━━━━━━━━━
505
+ β€’ Total Investment: β‚Ή{portfolio_summary.get('totalInvested', 0):,.2f}
506
+ β€’ Current Value: β‚Ή{portfolio_summary.get('currentValue', 0):,.2f}
507
+ β€’ Net P&L: β‚Ή{portfolio_summary.get('netPL', 0):,.2f} ({portfolio_summary.get('netPLPercentage', 0):+.2f}%)
508
+ β€’ Portfolio Status: {'🟒 Profitable' if portfolio_summary.get('netPL', 0) > 0 else 'πŸ”΄ Loss' if portfolio_summary.get('netPL', 0) < -1000 else '🟑 Neutral'}
509
+
510
+ πŸ“ˆ STOCK PERFORMANCE
511
+ ━━━━━━━━━━━━━━━━━━━━━━
512
+ β€’ Total Stocks: {total_stocks}
513
+ β€’ Profitable: {profitable_stocks} | Losing: {losing_stocks}
514
+ β€’ Best Performer: {top_gainer.get('stockName', 'N/A').replace('.NS', '')} ({top_gainer.get('profitLossPercentage', 0):+.2f}%)
515
+ β€’ Worst Performer: {top_loser.get('stockName', 'N/A').replace('.NS', '')} ({top_loser.get('profitLossPercentage', 0):.2f}%)
516
+
517
+ πŸ“Š INDIVIDUAL HOLDINGS
518
+ ━━━━━━━━━━━━━━━━━━━━━━"""
519
+
520
+ for holding in holdings:
521
+ status_emoji = "🟒" if holding.get('profitLoss', 0) > 0 else "πŸ”΄" if holding.get('profitLoss', 0) < 0 else "🟑"
522
+ summary += f"""
523
+ {status_emoji} {holding.get('stockName', 'Unknown').replace('.NS', '')}:
524
+ β€’ Shares: {holding.get('shares', 0)} | Price: β‚Ή{holding.get('currentPrice', 0):.2f}
525
+ β€’ Investment: β‚Ή{holding.get('investment', 0):,.0f} β†’ Current: β‚Ή{holding.get('currentValue', 0):,.0f}
526
+ β€’ P&L: β‚Ή{holding.get('profitLoss', 0):,.0f} ({holding.get('profitLossPercentage', 0):+.2f}%)
527
+ β€’ Sector: {holding.get('sector', 'N/A')}"""
528
+
529
+ summary += f"""
530
+
531
+ ⚠️ RISK ANALYSIS
532
+ ━━━━━━━━━━━━━━━━━━━━━━
533
+ β€’ Stocks near 52W low: {stocks_near_low}
534
+ β€’ Risk Level: {'πŸ”΄ HIGH' if stocks_near_low > 2 else '🟑 MEDIUM' if stocks_near_low > 0 else '🟒 LOW'}
535
+
536
+ πŸ“Š MARKET CONTEXT
537
+ ━━━━━━━━━━━━━━━━━━━━━━
538
+ β€’ Nifty 50: {market_context.get('niftyLevel', 'N/A')}
539
+ β€’ Portfolio vs Market: {market_context.get('portfolioVsMarket', 'N/A')}
540
+ β€’ Market Trend: {market_context.get('marketTrend', 'N/A')}
541
+
542
+ πŸ’‘ INSIGHTS & RECOMMENDATIONS
543
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"""
544
+
545
+ if portfolio_summary.get('netPLPercentage', 0) > 5:
546
+ summary += "\nβ€’ 🎯 Strong portfolio performance - consider partial profit booking"
547
+ elif portfolio_summary.get('netPLPercentage', 0) < -5:
548
+ summary += "\nβ€’ ⚠️ Portfolio underperforming - review individual positions"
549
+
550
+ if stocks_near_low > 1:
551
+ summary += f"\nβ€’ πŸ“‰ {stocks_near_low} stocks near 52W lows - monitor closely"
552
+
553
+ if profitable_stocks > losing_stocks:
554
+ summary += "\nβ€’ βœ… More winners than losers - good stock selection"
555
+
556
+ summary += f"""
557
+
558
+ πŸ”Έ Report generated at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
559
+ πŸ”Έ Enhanced API v2.0 - Flexible data processing
560
+ πŸ”Έ Disclaimer: This is for educational purposes only, not financial advice.
561
+ """
562
+
563
+ return summary
564
+
565
+ def _create_telegram_message(self, portfolio_summary, holdings, insights):
566
+ """Create Telegram-optimized message with flexible data handling"""
567
+
568
+ # Handle different data formats
569
+ if hasattr(portfolio_summary, 'dict'):
570
+ portfolio_summary = portfolio_summary.dict()
571
+ if hasattr(insights, 'dict'):
572
+ insights = insights.dict()
573
+
574
+ # Process holdings
575
+ processed_holdings = []
576
+ for h in holdings:
577
+ if hasattr(h, 'dict'):
578
+ processed_holdings.append(h.dict())
579
+ else:
580
+ processed_holdings.append(h)
581
+ holdings = processed_holdings
582
+
583
+ # Find top performers
584
+ perf_percentages = [h.get('profitLossPercentage', 0) for h in holdings]
585
+ if perf_percentages:
586
+ max_idx = perf_percentages.index(max(perf_percentages))
587
+ min_idx = perf_percentages.index(min(perf_percentages))
588
+ top_gainer = holdings[max_idx]
589
+ top_loser = holdings[min_idx]
590
+ else:
591
+ top_gainer = {}
592
+ top_loser = {}
593
+
594
+ status_emoji = "πŸ“ˆ" if portfolio_summary.get('netPL', 0) > 0 else "πŸ“‰" if portfolio_summary.get('netPL', 0) < -1000 else "➑️"
595
+
596
+ telegram_msg = f"""🏦 PORTFOLIO UPDATE (Enhanced)
597
+ πŸ“… {portfolio_summary.get('reportDate', 'Unknown')}
598
+
599
+ {status_emoji} PERFORMANCE
600
+ πŸ’° Value: β‚Ή{portfolio_summary.get('currentValue', 0):,.0f}
601
+ πŸ“Š P&L: {'+' if portfolio_summary.get('netPL', 0) > 0 else ''}β‚Ή{portfolio_summary.get('netPL', 0):,.0f} ({portfolio_summary.get('netPLPercentage', 0):+.2f}%)
602
+
603
+ πŸ† TOP MOVES
604
+ πŸ“ˆ Best: {top_gainer.get('stockName', 'N/A').replace('.NS', '')} {top_gainer.get('profitLossPercentage', 0):+.1f}%
605
+ πŸ“‰ Worst: {top_loser.get('stockName', 'N/A').replace('.NS', '')} {top_loser.get('profitLossPercentage', 0):.1f}%
606
+
607
+ πŸ“Š HOLDINGS"""
608
+
609
+ for holding in holdings[:5]: # Top 5 holdings
610
+ emoji = "🟒" if holding.get('profitLoss', 0) > 0 else "πŸ”΄"
611
+ telegram_msg += f"\n{emoji} {holding.get('stockName', 'Unknown').replace('.NS', '')}: {holding.get('profitLossPercentage', 0):+.1f}%"
612
+
613
+ profitable_count = len([h for h in holdings if h.get('profitLoss', 0) > 0])
614
+ telegram_msg += f"\n\nπŸ’‘ Status: {profitable_count}/{len(holdings)} profitable"
615
+ telegram_msg += f"\nπŸš€ Enhanced API v2.0"
616
+
617
+ return telegram_msg
618
+
619
+ # Initialize analyzer
620
+ analyzer = PortfolioAnalyzer()
621
+
622
+ @app.get("/", response_class=JSONResponse)
623
+ async def root():
624
+ """API Health Check"""
625
+ return {
626
+ "message": "Portfolio Analysis API v2.0 is running!",
627
+ "status": "healthy",
628
+ "version": "2.0.0",
629
+ "features": ["Flexible data handling", "Extended field support", "Enhanced analysis"],
630
+ "endpoints": {
631
+ "/analyze": "POST - Analyze portfolio data (flexible format)",
632
+ "/analyze-file": "POST - Upload JSON file for analysis",
633
+ "/sample": "GET - Get sample portfolio data",
634
+ "/docs": "GET - API documentation"
635
+ }
636
+ }
637
+
638
+ @app.post("/analyze", response_model=PortfolioResponse)
639
+ async def analyze_portfolio(portfolio_data: FlexiblePortfolioDataModel):
640
+ """
641
+ Analyze portfolio data with flexible input handling
642
+
643
+ Supports multiple data formats including:
644
+ - Standard portfolio structure
645
+ - Extended data with metadata, debug info, etc.
646
+ - Stringified JSON in data/output fields
647
+ """
648
+ try:
649
+ result = analyzer.analyze_portfolio(portfolio_data)
650
+ return result
651
+ except ValidationError as e:
652
+ raise HTTPException(status_code=422, detail=f"Invalid data format: {e}")
653
+ except Exception as e:
654
+ raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}")
655
+
656
+ @app.post("/analyze-file")
657
+ async def analyze_portfolio_file(file: UploadFile = File(...)):
658
+ """Upload JSON file and analyze portfolio"""
659
+ try:
660
+ if not file.filename.endswith('.json'):
661
+ raise HTTPException(status_code=400, detail="Please upload a JSON file")
662
+
663
+ content = await file.read()
664
+ portfolio_data = json.loads(content.decode('utf-8'))
665
+ validated_data = FlexiblePortfolioDataModel(**portfolio_data)
666
+ result = analyzer.analyze_portfolio(validated_data)
667
+ return result
668
+
669
+ except json.JSONDecodeError:
670
+ raise HTTPException(status_code=400, detail="Invalid JSON format")
671
+ except ValidationError as e:
672
+ raise HTTPException(status_code=422, detail=f"Invalid data structure: {e}")
673
+ except Exception as e:
674
+ raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}")
675
+
676
+ @app.get("/sample")
677
+ async def get_sample_data():
678
+ """Get sample portfolio data"""
679
+ sample_data = {
680
+ "portfolioSummary": {
681
+ "totalInvested": 213600,
682
+ "currentValue": 214327.5,
683
+ "netPL": 727.5,
684
+ "netPLPercentage": 0.34,
685
+ "reportDate": "September 27, 2025"
686
+ },
687
+ "holdings": [
688
+ {
689
+ "stockName": "RELIANCE.NS",
690
+ "shares": 20,
691
+ "purchasePrice": 1400,
692
+ "currentPrice": 1375,
693
+ "investment": 28000,
694
+ "currentValue": 27500,
695
+ "profitLoss": -500,
696
+ "profitLossPercentage": -1.79,
697
+ "fiftyTwoWeekLow": 1114.85,
698
+ "fiftyTwoWeekHigh": 1551,
699
+ "positionInRange": 59.5,
700
+ "sector": "Energy"
701
+ },
702
+ {
703
+ "stockName": "HDFCBANK.NS",
704
+ "shares": 22,
705
+ "purchasePrice": 800,
706
+ "currentPrice": 950,
707
+ "investment": 17600,
708
+ "currentValue": 20900,
709
+ "profitLoss": 3300,
710
+ "profitLossPercentage": 18.75,
711
+ "fiftyTwoWeekLow": 806.5,
712
+ "fiftyTwoWeekHigh": 1018.85,
713
+ "positionInRange": 67.6,
714
+ "sector": "Banking"
715
+ }
716
+ ],
717
+ "allocation": [
718
+ {"stock": "HDFC", "percentage": 54.4, "value": 20900},
719
+ {"stock": "RIL", "percentage": 45.6, "value": 27500}
720
+ ]
721
+ }
722
+ return sample_data
723
+
724
+ @app.get("/health")
725
+ async def health_check():
726
+ """Detailed health check"""
727
+ return {
728
+ "status": "healthy",
729
+ "timestamp": datetime.now().isoformat(),
730
+ "version": "2.0.0",
731
+ "features": {
732
+ "flexible_data_handling": "enabled",
733
+ "extended_field_support": "enabled",
734
+ "chart_generation": "enabled",
735
+ "enhanced_analysis": "enabled"
736
+ },
737
+ "dependencies": {
738
+ "matplotlib": "available",
739
+ "pandas": "available",
740
+ "numpy": "available",
741
+ "pydantic": "available"
742
+ }
743
+ }
744
+
745
+ if __name__ == "__main__":
746
+ uvicorn.run(
747
+ "main:app",
748
+ host="0.0.0.0",
749
+ port=int(os.getenv("PORT", 7860)),
750
+ reload=False
751
  )