Karan6124 commited on
Commit
61b3e67
·
1 Parent(s): 582e926

feat: add historical chart range selector (1D, 5D, 1M, 6M, YTD, 1Y, 5Y, MAX) powered by yfinance

Browse files
backend/app/graphql/schema.py CHANGED
@@ -130,11 +130,113 @@ class Query:
130
  return await crud.get_user_alerts(db, user.id)
131
 
132
  @strawberry.field
133
- async def stock_history(self, info: Info, ticker: str, limit: int = 100) -> List[StockHistoryType]:
134
- """Fetch historical 1-minute aggregated candles for a ticker."""
135
- # Open query: no login required to look at historical charts
136
  db = info.context["db"]
137
- return await crud.get_stock_history(db, ticker, limit)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
 
139
 
140
  # ==========================================
 
130
  return await crud.get_user_alerts(db, user.id)
131
 
132
  @strawberry.field
133
+ async def stock_history(self, info: Info, ticker: str, range: str = "1d") -> List[StockHistoryType]:
134
+ """Fetch historical aggregated candles for a ticker based on range (1d, 5d, 1m, 6m, ytd, 1y, 5y, max)."""
 
135
  db = info.context["db"]
136
+
137
+ range_mapping = {
138
+ "1d": {"period": "1d", "interval": "2m"},
139
+ "5d": {"period": "5d", "interval": "15m"},
140
+ "1m": {"period": "1mo", "interval": "1d"},
141
+ "6m": {"period": "6mo", "interval": "1d"},
142
+ "ytd": {"period": "ytd", "interval": "1d"},
143
+ "1y": {"period": "1y", "interval": "1d"},
144
+ "5y": {"period": "5y", "interval": "1wk"},
145
+ "max": {"period": "max", "interval": "1mo"}
146
+ }
147
+
148
+ selected_range = range.lower()
149
+ if selected_range not in range_mapping:
150
+ selected_range = "1d"
151
+
152
+ config = range_mapping[selected_range]
153
+
154
+ # If it's 1d, let's try local DB first
155
+ if selected_range == "1d":
156
+ try:
157
+ local_data = await crud.get_stock_history(db, ticker, limit=100)
158
+ if len(local_data) > 10:
159
+ return [
160
+ StockHistoryType(
161
+ id=h.id,
162
+ ticker=h.ticker,
163
+ timestamp=h.timestamp,
164
+ open=h.open,
165
+ high=h.high,
166
+ low=h.low,
167
+ close=h.close,
168
+ volume=h.volume
169
+ ) for h in local_data
170
+ ]
171
+ except Exception as db_err:
172
+ print(f"Error querying local DB for ticker history: {db_err}")
173
+
174
+ # Otherwise, dynamically fetch from Yahoo Finance in a thread pool
175
+ try:
176
+ import yfinance as yf
177
+ loop = asyncio.get_event_loop()
178
+ yf_ticker = yf.Ticker(ticker)
179
+
180
+ df = await loop.run_in_executor(
181
+ None,
182
+ lambda: yf_ticker.history(period=config["period"], interval=config["interval"])
183
+ )
184
+
185
+ history_list = []
186
+ if not df.empty:
187
+ df = df.reset_index()
188
+
189
+ time_col = None
190
+ for col in ['Date', 'Datetime', 'index', 'timestamp']:
191
+ if col in df.columns:
192
+ time_col = col
193
+ break
194
+
195
+ if time_col:
196
+ # Limit to last 350 points to ensure smooth chart performance
197
+ df = df.tail(350)
198
+ for i, row in df.iterrows():
199
+ ts = row[time_col]
200
+ if hasattr(ts, 'to_pydatetime'):
201
+ ts_dt = ts.to_pydatetime()
202
+ elif isinstance(ts, str):
203
+ ts_dt = datetime.datetime.fromisoformat(ts)
204
+ else:
205
+ ts_dt = ts
206
+
207
+ if ts_dt.tzinfo is not None:
208
+ ts_dt = ts_dt.replace(tzinfo=None)
209
+
210
+ history_list.append(
211
+ StockHistoryType(
212
+ id=i,
213
+ ticker=ticker.upper(),
214
+ timestamp=ts_dt,
215
+ open=float(row["Open"]),
216
+ high=float(row["High"]),
217
+ low=float(row["Low"]),
218
+ close=float(row["Close"]),
219
+ volume=int(row["Volume"]) if "Volume" in row else 0
220
+ )
221
+ )
222
+ return history_list
223
+ except Exception as e:
224
+ print(f"Error fetching yfinance history for {ticker}: {str(e)}")
225
+ # Failover fallback
226
+ local_data = await crud.get_stock_history(db, ticker, limit=100)
227
+ return [
228
+ StockHistoryType(
229
+ id=h.id,
230
+ ticker=h.ticker,
231
+ timestamp=h.timestamp,
232
+ open=h.open,
233
+ high=h.high,
234
+ low=h.low,
235
+ close=h.close,
236
+ volume=h.volume
237
+ ) for h in local_data
238
+ ]
239
+
240
 
241
 
242
  # ==========================================
frontend/src/App.css CHANGED
@@ -1418,3 +1418,37 @@
1418
  font-size: 13px;
1419
  z-index: 10;
1420
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1418
  font-size: 13px;
1419
  z-index: 10;
1420
  }
1421
+
1422
+ /* Chart Range Selector */
1423
+ .chart-range-selector {
1424
+ display: flex;
1425
+ background: rgba(255, 255, 255, 0.03);
1426
+ border: 1px solid var(--border-glass);
1427
+ border-radius: 8px;
1428
+ padding: 2px;
1429
+ }
1430
+
1431
+ .range-btn {
1432
+ background: transparent;
1433
+ color: var(--text-secondary);
1434
+ font-size: 11px;
1435
+ font-weight: 600;
1436
+ padding: 5px 10px;
1437
+ border-radius: 6px;
1438
+ cursor: pointer;
1439
+ transition: all 0.2s ease;
1440
+ border: none;
1441
+ }
1442
+
1443
+ .range-btn:hover {
1444
+ color: var(--text-primary);
1445
+ background: rgba(255, 255, 255, 0.03);
1446
+ }
1447
+
1448
+ .range-btn.active {
1449
+ color: #08060e;
1450
+ background: var(--neon-cyan);
1451
+ box-shadow: 0 0 10px rgba(0, 242, 254, 0.2);
1452
+ font-weight: 700;
1453
+ }
1454
+
frontend/src/App.tsx CHANGED
@@ -51,6 +51,7 @@ export default function App() {
51
  const [activeTicker, setActiveTicker] = useState<string>('AAPL');
52
  const [chartData, setChartData] = useState<any[]>([]);
53
  const [alerts, setAlerts] = useState<any[]>([]);
 
54
 
55
  // AI Insights State
56
  const [insight, setInsight] = useState<any>(null);
@@ -138,18 +139,31 @@ export default function App() {
138
  const fetchHistory = async () => {
139
  try {
140
  const historyData = await graphqlRequest(`
141
- query GetHistory($ticker: String!) {
142
- stockHistory(ticker: $ticker, limit: 30) {
143
  timestamp
144
  close
145
  }
146
  }
147
- `, { ticker: activeTicker });
148
 
149
- const formatted = historyData.stockHistory.map((h: any) => ({
150
- time: new Date(h.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
151
- price: parseFloat(h.close.toFixed(2)),
152
- }));
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  setChartData(formatted);
154
  } catch (err) {
155
  console.error('Failed to load stock history', err);
@@ -159,7 +173,7 @@ export default function App() {
159
  fetchHistory();
160
  // Reset insight when active stock changes
161
  setInsight(null);
162
- }, [token, activeTicker]);
163
 
164
  // 4. WebSocket Live Price Streaming Subscription (graphql-transport-ws protocol)
165
  useEffect(() => {
@@ -446,6 +460,8 @@ export default function App() {
446
  loadingInsight={loadingInsight}
447
  insightError={insightError}
448
  showRecharge={showRecharge}
 
 
449
  onSelectTicker={setActiveTicker}
450
  onAddTicker={handleAddWatchlist}
451
  onRemoveTicker={handleRemoveWatchlist}
 
51
  const [activeTicker, setActiveTicker] = useState<string>('AAPL');
52
  const [chartData, setChartData] = useState<any[]>([]);
53
  const [alerts, setAlerts] = useState<any[]>([]);
54
+ const [chartRange, setChartRange] = useState<string>('1d');
55
 
56
  // AI Insights State
57
  const [insight, setInsight] = useState<any>(null);
 
139
  const fetchHistory = async () => {
140
  try {
141
  const historyData = await graphqlRequest(`
142
+ query GetHistory($ticker: String!, $range: String!) {
143
+ stockHistory(ticker: $ticker, range: $range) {
144
  timestamp
145
  close
146
  }
147
  }
148
+ `, { ticker: activeTicker, range: chartRange });
149
 
150
+ const formatted = historyData.stockHistory.map((h: any) => {
151
+ const dateObj = new Date(h.timestamp);
152
+ let timeLabel = '';
153
+ if (chartRange === '1d') {
154
+ timeLabel = dateObj.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
155
+ } else if (chartRange === '5d') {
156
+ timeLabel = dateObj.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + dateObj.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
157
+ } else if (chartRange === '1y' || chartRange === '5y' || chartRange === 'max') {
158
+ timeLabel = dateObj.toLocaleDateString([], { year: '2-digit', month: 'short' });
159
+ } else {
160
+ timeLabel = dateObj.toLocaleDateString([], { month: 'short', day: 'numeric' });
161
+ }
162
+ return {
163
+ time: timeLabel,
164
+ price: parseFloat(h.close.toFixed(2)),
165
+ };
166
+ });
167
  setChartData(formatted);
168
  } catch (err) {
169
  console.error('Failed to load stock history', err);
 
173
  fetchHistory();
174
  // Reset insight when active stock changes
175
  setInsight(null);
176
+ }, [token, activeTicker, chartRange]);
177
 
178
  // 4. WebSocket Live Price Streaming Subscription (graphql-transport-ws protocol)
179
  useEffect(() => {
 
460
  loadingInsight={loadingInsight}
461
  insightError={insightError}
462
  showRecharge={showRecharge}
463
+ chartRange={chartRange}
464
+ onRangeChange={setChartRange}
465
  onSelectTicker={setActiveTicker}
466
  onAddTicker={handleAddWatchlist}
467
  onRemoveTicker={handleRemoveWatchlist}
frontend/src/components/StockChart.tsx CHANGED
@@ -9,11 +9,24 @@ interface ChartDataPoint {
9
  interface StockChartProps {
10
  activeTicker: string;
11
  chartData: ChartDataPoint[];
 
 
12
  }
13
 
14
- export default function StockChart({ activeTicker, chartData }: StockChartProps) {
15
  const currentPrice = chartData.length > 0 ? chartData[chartData.length - 1].price : null;
16
 
 
 
 
 
 
 
 
 
 
 
 
17
  return (
18
  <div className="glass-panel chart-panel">
19
  <div className="chart-header">
@@ -27,6 +40,18 @@ export default function StockChart({ activeTicker, chartData }: StockChartProps)
27
  <span>Live</span>
28
  </div>
29
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
30
  </div>
31
 
32
  <div className="chart-container">
 
9
  interface StockChartProps {
10
  activeTicker: string;
11
  chartData: ChartDataPoint[];
12
+ chartRange: string;
13
+ onRangeChange: (range: string) => void;
14
  }
15
 
16
+ export default function StockChart({ activeTicker, chartData, chartRange, onRangeChange }: StockChartProps) {
17
  const currentPrice = chartData.length > 0 ? chartData[chartData.length - 1].price : null;
18
 
19
+ const ranges = [
20
+ { label: '1D', value: '1d' },
21
+ { label: '5D', value: '5d' },
22
+ { label: '1M', value: '1m' },
23
+ { label: '6M', value: '6m' },
24
+ { label: 'YTD', value: 'ytd' },
25
+ { label: '1Y', value: '1y' },
26
+ { label: '5Y', value: '5y' },
27
+ { label: 'MAX', value: 'max' }
28
+ ];
29
+
30
  return (
31
  <div className="glass-panel chart-panel">
32
  <div className="chart-header">
 
40
  <span>Live</span>
41
  </div>
42
  </div>
43
+
44
+ <div className="chart-range-selector">
45
+ {ranges.map((r) => (
46
+ <button
47
+ key={r.value}
48
+ className={`range-btn ${chartRange === r.value ? 'active' : ''}`}
49
+ onClick={() => onRangeChange(r.value)}
50
+ >
51
+ {r.label}
52
+ </button>
53
+ ))}
54
+ </div>
55
  </div>
56
 
57
  <div className="chart-container">
frontend/src/pages/Dashboard.tsx CHANGED
@@ -16,6 +16,8 @@ interface DashboardProps {
16
  loadingInsight: boolean;
17
  insightError: string | null;
18
  showRecharge: boolean;
 
 
19
  onSelectTicker: (ticker: string) => void;
20
  onAddTicker: (ticker: string) => Promise<void>;
21
  onRemoveTicker: (ticker: string) => Promise<void>;
@@ -40,6 +42,8 @@ export default function Dashboard({
40
  loadingInsight,
41
  insightError,
42
  showRecharge,
 
 
43
  onSelectTicker,
44
  onAddTicker,
45
  onRemoveTicker,
@@ -81,6 +85,8 @@ export default function Dashboard({
81
  <StockChart
82
  activeTicker={activeTicker}
83
  chartData={chartData}
 
 
84
  />
85
  <AIAnalyst
86
  activeTicker={activeTicker}
 
16
  loadingInsight: boolean;
17
  insightError: string | null;
18
  showRecharge: boolean;
19
+ chartRange: string;
20
+ onRangeChange: (range: string) => void;
21
  onSelectTicker: (ticker: string) => void;
22
  onAddTicker: (ticker: string) => Promise<void>;
23
  onRemoveTicker: (ticker: string) => Promise<void>;
 
42
  loadingInsight,
43
  insightError,
44
  showRecharge,
45
+ chartRange,
46
+ onRangeChange,
47
  onSelectTicker,
48
  onAddTicker,
49
  onRemoveTicker,
 
85
  <StockChart
86
  activeTicker={activeTicker}
87
  chartData={chartData}
88
+ chartRange={chartRange}
89
+ onRangeChange={onRangeChange}
90
  />
91
  <AIAnalyst
92
  activeTicker={activeTicker}