1+ """
2+ Chart generation service using matplotlib.
3+ """
4+ import io
5+ import logging
6+ from datetime import datetime
7+ from typing import Optional
8+
9+ import matplotlib
10+ matplotlib .use ('Agg' )
11+ import matplotlib .pyplot as plt
12+ import matplotlib .dates as mdates
13+ import numpy as np
14+
15+ logger = logging .getLogger (__name__ )
16+
17+
18+ class ChartService :
19+
20+ def _format_price (self , price : float ) -> str :
21+ """Format price nicely."""
22+ if price >= 10000 :
23+ return f"${ price :,.0f} "
24+ elif price >= 100 :
25+ return f"${ price :,.2f} "
26+ elif price >= 1 :
27+ return f"${ price :.4f} "
28+ else :
29+ return f"${ price :.6f} "
30+
31+ def generate_price_chart (
32+ self ,
33+ timestamps : list ,
34+ prices : list ,
35+ crypto_name : str ,
36+ timeframe : str = "24h" ,
37+ hours : int = 24
38+ ) -> Optional [bytes ]:
39+ """Generate a price chart and return as PNG bytes."""
40+ if not timestamps or not prices or len (timestamps ) < 2 :
41+ return None
42+
43+ try :
44+ fig = plt .figure (figsize = (14 , 8 ), facecolor = '#0d1117' )
45+ ax = fig .add_subplot (111 , facecolor = '#0d1117' )
46+
47+ dates = [datetime .fromtimestamp (ts ) for ts in timestamps ]
48+ prices_arr = np .array (prices )
49+
50+ start_price = prices [0 ]
51+ end_price = prices [- 1 ]
52+ change = ((end_price - start_price ) / start_price ) * 100
53+ line_color = '#00d26a' if change >= 0 else '#ff4757'
54+
55+ ax .plot (dates , prices_arr , color = line_color , linewidth = 2.5 , zorder = 3 )
56+
57+ ax .fill_between (dates , prices_arr ,
58+ alpha = 0.15 , color = line_color , zorder = 2 )
59+
60+ ax .scatter (dates , prices_arr , color = line_color , s = 30 , zorder = 4 ,
61+ edgecolors = '#0d1117' , linewidths = 0.5 )
62+
63+ min_idx = np .argmin (prices_arr )
64+ max_idx = np .argmax (prices_arr )
65+
66+ ax .scatter (dates [min_idx ], prices_arr [min_idx ], color = '#ff4757' ,
67+ s = 100 , zorder = 5 , marker = 'v' , edgecolors = 'white' , linewidths = 1 )
68+ ax .scatter (dates [max_idx ], prices_arr [max_idx ], color = '#00d26a' ,
69+ s = 100 , zorder = 5 , marker = '^' , edgecolors = 'white' , linewidths = 1 )
70+
71+ ax .annotate (f'LOW\n { self ._format_price (prices_arr [min_idx ])} ' ,
72+ xy = (dates [min_idx ], prices_arr [min_idx ]),
73+ xytext = (10 , - 30 ), textcoords = 'offset points' ,
74+ fontsize = 8 , color = '#888888' ,
75+ bbox = dict (boxstyle = 'round,pad=0.3' , facecolor = '#161b22' ,
76+ edgecolor = '#30363d' , pad = 0.3 ),
77+ arrowprops = dict (arrowstyle = '->' , color = '#ff4757' , lw = 1 ))
78+
79+ ax .annotate (f'HIGH\n { self ._format_price (prices_arr [max_idx ])} ' ,
80+ xy = (dates [max_idx ], prices_arr [max_idx ]),
81+ xytext = (10 , 20 ), textcoords = 'offset points' ,
82+ fontsize = 8 , color = '#888888' ,
83+ bbox = dict (boxstyle = 'round,pad=0.3' , facecolor = '#161b22' ,
84+ edgecolor = '#30363d' , pad = 0.3 ),
85+ arrowprops = dict (arrowstyle = '->' , color = '#00d26a' , lw = 1 ))
86+
87+ ax .annotate (f'{ self ._format_price (end_price )} ' ,
88+ xy = (dates [- 1 ], prices_arr [- 1 ]),
89+ xytext = (10 , 0 ), textcoords = 'offset points' ,
90+ fontsize = 11 , color = line_color , fontweight = 'bold' ,
91+ bbox = dict (boxstyle = 'round,pad=0.4' , facecolor = '#161b22' ,
92+ edgecolor = line_color , pad = 0.4 ),
93+ arrowprops = dict (arrowstyle = '->' , color = line_color , lw = 1 ))
94+
95+ ax .set_title (
96+ f'{ crypto_name } | { timeframe } | { change :+.2f} %' ,
97+ fontsize = 16 , fontweight = 'bold' , color = 'white' ,
98+ pad = 20 , loc = 'center'
99+ )
100+
101+ ax .set_ylabel ('Price (USD)' , fontsize = 11 , color = '#888888' , labelpad = 10 )
102+ ax .set_xlabel ('Time' , fontsize = 11 , color = '#888888' , labelpad = 10 )
103+
104+ ax .tick_params (colors = '#888888' , labelsize = 9 )
105+ ax .spines ['bottom' ].set_color ('#30363d' )
106+ ax .spines ['left' ].set_color ('#30363d' )
107+ ax .spines ['top' ].set_visible (False )
108+ ax .spines ['right' ].set_visible (False )
109+
110+ if hours <= 24 :
111+ ax .xaxis .set_major_formatter (mdates .DateFormatter ('%H:%M' ))
112+ elif hours <= 168 :
113+ ax .xaxis .set_major_formatter (mdates .DateFormatter ('%b %d %H:%M' ))
114+ else :
115+ ax .xaxis .set_major_formatter (mdates .DateFormatter ('%b %d' ))
116+ ax .xaxis .set_major_locator (mdates .AutoDateLocator ())
117+
118+ price_min = min (prices_arr )
119+ price_max = max (prices_arr )
120+ price_range = price_max - price_min
121+ ax .set_ylim (price_min - price_range * 0.1 , price_max + price_range * 0.15 )
122+
123+ fig .autofmt_xdate ()
124+
125+ ax .grid (True , alpha = 0.1 , color = '#30363d' , linestyle = '--' , zorder = 1 )
126+
127+ for spine in ax .spines .values ():
128+ spine .set_zorder (0 )
129+
130+ buf = io .BytesIO ()
131+ plt .savefig (
132+ buf ,
133+ format = 'png' ,
134+ bbox_inches = 'tight' ,
135+ facecolor = '#0d1117' ,
136+ edgecolor = 'none' ,
137+ dpi = 100
138+ )
139+ buf .seek (0 )
140+ plt .close (fig )
141+
142+ return buf .read ()
143+
144+ except Exception as e :
145+ logger .error (f"Failed to generate chart for { crypto_name } : { e } " )
146+ return None
147+
148+ def _downsample (self , timestamps : list , prices : list , max_points : int = 500 ) -> tuple :
149+ """Downsample data to max_points for performance."""
150+ if len (timestamps ) <= max_points :
151+ return timestamps , prices
152+
153+ step = len (timestamps ) // max_points
154+ return timestamps [::step ], prices [::step ]
155+
156+ async def get_chart_bytes (
157+ self ,
158+ db ,
159+ crypto : str ,
160+ hours : int = 24 ,
161+ timeframe_str : str = None
162+ ) -> Optional [bytes ]:
163+ """Get price history from DB and generate chart."""
164+ limit = 1000
165+ history = await db .get_price_history (crypto , hours = hours , limit = limit )
166+
167+ if not history or len (history ) < 2 :
168+ return None
169+
170+ timestamps = [h [0 ] for h in history ]
171+ prices = [float (h [1 ]) for h in history ]
172+
173+ if not timeframe_str :
174+ if hours <= 24 :
175+ timeframe_str = f"{ hours } h"
176+ elif hours <= 720 :
177+ timeframe_str = f"{ hours // 24 } d"
178+ elif hours <= 8760 :
179+ timeframe_str = f"{ hours // 24 } d"
180+ else :
181+ timeframe_str = f"{ hours // 720 } mo"
182+ return self .generate_price_chart (timestamps , prices , crypto .upper (), timeframe_str , hours )
0 commit comments