feat: add historical chart range selector (1D, 5D, 1M, 6M, YTD, 1Y, 5Y, MAX) powered by yfinance
Browse files- backend/app/graphql/schema.py +106 -4
- frontend/src/App.css +34 -0
- frontend/src/App.tsx +24 -8
- frontend/src/components/StockChart.tsx +26 -1
- frontend/src/pages/Dashboard.tsx +6 -0
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,
|
| 134 |
-
"""Fetch historical
|
| 135 |
-
# Open query: no login required to look at historical charts
|
| 136 |
db = info.context["db"]
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 143 |
timestamp
|
| 144 |
close
|
| 145 |
}
|
| 146 |
}
|
| 147 |
-
`, { ticker: activeTicker });
|
| 148 |
|
| 149 |
-
const formatted = historyData.stockHistory.map((h: any) =>
|
| 150 |
-
|
| 151 |
-
|
| 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}
|