Spaces:
Sleeping
Sleeping
Update app/services/market_data.py
Browse files- app/services/market_data.py +88 -76
app/services/market_data.py
CHANGED
|
@@ -11,15 +11,59 @@ settings = get_settings()
|
|
| 11 |
|
| 12 |
ALPHA_VANTAGE_URL = "https://www.alphavantage.co/query"
|
| 13 |
|
| 14 |
-
_CACHE = {}
|
| 15 |
-
_CACHE_TTL = 60 # seconds
|
| 16 |
|
| 17 |
|
| 18 |
# -------------------------
|
| 19 |
# Helpers
|
| 20 |
# -------------------------
|
| 21 |
def is_crypto(ticker: str) -> bool:
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
|
| 25 |
# -------------------------
|
|
@@ -30,26 +74,20 @@ def _fetch_stock_history(ticker: str) -> pd.DataFrame:
|
|
| 30 |
"function": "TIME_SERIES_DAILY",
|
| 31 |
"symbol": ticker,
|
| 32 |
"apikey": settings.alpha_vantage_api_key,
|
| 33 |
-
"outputsize": "compact",
|
| 34 |
}
|
| 35 |
|
| 36 |
-
|
| 37 |
-
data = response.json()
|
| 38 |
|
| 39 |
if "Time Series (Daily)" not in data:
|
| 40 |
if "Note" in data or "Information" in data:
|
| 41 |
-
raise HTTPException(
|
| 42 |
-
|
| 43 |
-
detail="Alpha Vantage rate limit exceeded",
|
| 44 |
-
)
|
| 45 |
-
raise HTTPException(
|
| 46 |
-
status_code=400,
|
| 47 |
-
detail="Invalid stock ticker symbol",
|
| 48 |
-
)
|
| 49 |
|
| 50 |
-
|
|
|
|
|
|
|
| 51 |
|
| 52 |
-
df = pd.DataFrame.from_dict(ts, orient="index")
|
| 53 |
df.rename(
|
| 54 |
columns={
|
| 55 |
"1. open": "Open",
|
|
@@ -62,17 +100,13 @@ def _fetch_stock_history(ticker: str) -> pd.DataFrame:
|
|
| 62 |
)
|
| 63 |
|
| 64 |
df = df[["Open", "High", "Low", "Close", "Volume"]]
|
| 65 |
-
df = df.apply(pd.to_numeric, errors="coerce")
|
| 66 |
-
df.dropna(inplace=True)
|
| 67 |
|
| 68 |
df.index = pd.to_datetime(df.index)
|
| 69 |
df.sort_index(inplace=True)
|
| 70 |
|
| 71 |
if len(df) < settings.history_window + 50:
|
| 72 |
-
raise HTTPException(
|
| 73 |
-
status_code=400,
|
| 74 |
-
detail="Not enough historical stock data",
|
| 75 |
-
)
|
| 76 |
|
| 77 |
return df
|
| 78 |
|
|
@@ -81,89 +115,58 @@ def _fetch_stock_history(ticker: str) -> pd.DataFrame:
|
|
| 81 |
# CRYPTO DATA
|
| 82 |
# -------------------------
|
| 83 |
def _fetch_crypto_history(ticker: str) -> pd.DataFrame:
|
| 84 |
-
# Normalize ticker
|
| 85 |
if "-" in ticker:
|
| 86 |
-
symbol, market = ticker.split("-")
|
| 87 |
else:
|
| 88 |
-
symbol, market = ticker
|
| 89 |
|
| 90 |
params = {
|
| 91 |
"function": "DIGITAL_CURRENCY_DAILY",
|
| 92 |
-
"symbol": symbol,
|
| 93 |
-
"market": market,
|
| 94 |
"apikey": settings.alpha_vantage_api_key,
|
| 95 |
}
|
| 96 |
|
| 97 |
-
|
| 98 |
-
data = response.json()
|
| 99 |
|
| 100 |
if "Time Series (Digital Currency Daily)" not in data:
|
| 101 |
if "Note" in data or "Information" in data:
|
| 102 |
-
raise HTTPException(
|
| 103 |
-
|
| 104 |
-
detail="Alpha Vantage rate limit exceeded",
|
| 105 |
-
)
|
| 106 |
-
raise HTTPException(
|
| 107 |
-
status_code=400,
|
| 108 |
-
detail="Invalid crypto ticker symbol",
|
| 109 |
-
)
|
| 110 |
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
# ---- 🔑 Dynamic USD column detection (CRITICAL FIX) ----
|
| 116 |
-
try:
|
| 117 |
-
open_col = next(c for c in df.columns if "open (USD)" in c)
|
| 118 |
-
high_col = next(c for c in df.columns if "high (USD)" in c)
|
| 119 |
-
low_col = next(c for c in df.columns if "low (USD)" in c)
|
| 120 |
-
close_col = next(c for c in df.columns if "close (USD)" in c)
|
| 121 |
-
except StopIteration:
|
| 122 |
-
raise HTTPException(
|
| 123 |
-
status_code=500,
|
| 124 |
-
detail="Unexpected crypto data format from Alpha Vantage",
|
| 125 |
-
)
|
| 126 |
-
|
| 127 |
-
volume_col = "5. volume"
|
| 128 |
-
|
| 129 |
-
df = df[[open_col, high_col, low_col, close_col, volume_col]]
|
| 130 |
|
| 131 |
-
df
|
| 132 |
|
| 133 |
-
# ---- Clean & normalize ----
|
| 134 |
df = df.apply(pd.to_numeric, errors="coerce")
|
| 135 |
df.dropna(subset=["Open", "High", "Low", "Close"], inplace=True)
|
| 136 |
|
| 137 |
df.index = pd.to_datetime(df.index)
|
| 138 |
df.sort_index(inplace=True)
|
| 139 |
|
| 140 |
-
# ---- Indicator safety buffer ----
|
| 141 |
if len(df) < settings.history_window + 50:
|
| 142 |
-
raise HTTPException(
|
| 143 |
-
status_code=400,
|
| 144 |
-
detail="Not enough historical crypto data after cleaning",
|
| 145 |
-
)
|
| 146 |
|
| 147 |
return df
|
| 148 |
|
| 149 |
|
| 150 |
-
|
| 151 |
# -------------------------
|
| 152 |
# Cached unified fetcher
|
| 153 |
# -------------------------
|
| 154 |
def fetch_raw_history(ticker: str) -> pd.DataFrame:
|
| 155 |
-
now = time.time()
|
| 156 |
key = ticker.upper()
|
|
|
|
| 157 |
|
| 158 |
-
if key in _CACHE:
|
| 159 |
-
|
| 160 |
-
if now - cached["ts"] < _CACHE_TTL:
|
| 161 |
-
return cached["df"]
|
| 162 |
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
|
|
|
| 167 |
|
| 168 |
_CACHE[key] = {"df": df, "ts": now}
|
| 169 |
return df
|
|
@@ -175,12 +178,21 @@ def fetch_raw_history(ticker: str) -> pd.DataFrame:
|
|
| 175 |
def get_enriched_history(ticker: str) -> Tuple[pd.DataFrame, pd.DataFrame]:
|
| 176 |
df_raw = fetch_raw_history(ticker)
|
| 177 |
df_tech = add_technical_indicators(df_raw)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
return df_raw, df_tech
|
| 179 |
|
| 180 |
|
| 181 |
def last_n_candles(df: pd.DataFrame, n: int) -> list[dict]:
|
| 182 |
-
tail = df.tail(n)
|
| 183 |
return [
|
| 184 |
-
{
|
| 185 |
-
|
|
|
|
|
|
|
|
|
|
| 186 |
]
|
|
|
|
| 11 |
|
| 12 |
ALPHA_VANTAGE_URL = "https://www.alphavantage.co/query"
|
| 13 |
|
| 14 |
+
_CACHE: dict[str, dict] = {}
|
| 15 |
+
_CACHE_TTL = 60 # seconds (safe for AV free tier)
|
| 16 |
|
| 17 |
|
| 18 |
# -------------------------
|
| 19 |
# Helpers
|
| 20 |
# -------------------------
|
| 21 |
def is_crypto(ticker: str) -> bool:
|
| 22 |
+
t = ticker.upper()
|
| 23 |
+
return "-" in t or t in {"BTC", "ETH", "SOL", "BNB", "XRP", "DOGE"}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _safe_get_json(params: dict) -> dict:
|
| 27 |
+
try:
|
| 28 |
+
resp = requests.get(ALPHA_VANTAGE_URL, params=params, timeout=10)
|
| 29 |
+
return resp.json()
|
| 30 |
+
except Exception:
|
| 31 |
+
raise HTTPException(
|
| 32 |
+
status_code=503,
|
| 33 |
+
detail="Market data provider unavailable",
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _detect_ohlcv_columns(df: pd.DataFrame) -> pd.DataFrame:
|
| 38 |
+
"""
|
| 39 |
+
Robust OHLCV resolver for Alpha Vantage crypto.
|
| 40 |
+
Works for:
|
| 41 |
+
- 1a / 1b
|
| 42 |
+
- USD / non-USD
|
| 43 |
+
- future schema changes
|
| 44 |
+
"""
|
| 45 |
+
def find(keywords):
|
| 46 |
+
for c in df.columns:
|
| 47 |
+
name = c.lower()
|
| 48 |
+
if all(k in name for k in keywords):
|
| 49 |
+
return c
|
| 50 |
+
return None
|
| 51 |
+
|
| 52 |
+
open_col = find(["open"])
|
| 53 |
+
high_col = find(["high"])
|
| 54 |
+
low_col = find(["low"])
|
| 55 |
+
close_col = find(["close"])
|
| 56 |
+
volume_col = find(["volume"])
|
| 57 |
+
|
| 58 |
+
if not all([open_col, high_col, low_col, close_col, volume_col]):
|
| 59 |
+
raise HTTPException(
|
| 60 |
+
status_code=500,
|
| 61 |
+
detail=f"Unsupported crypto data format from Alpha Vantage: {list(df.columns)}",
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
df = df[[open_col, high_col, low_col, close_col, volume_col]]
|
| 65 |
+
df.columns = ["Open", "High", "Low", "Close", "Volume"]
|
| 66 |
+
return df
|
| 67 |
|
| 68 |
|
| 69 |
# -------------------------
|
|
|
|
| 74 |
"function": "TIME_SERIES_DAILY",
|
| 75 |
"symbol": ticker,
|
| 76 |
"apikey": settings.alpha_vantage_api_key,
|
| 77 |
+
"outputsize": "compact", # FREE tier only
|
| 78 |
}
|
| 79 |
|
| 80 |
+
data = _safe_get_json(params)
|
|
|
|
| 81 |
|
| 82 |
if "Time Series (Daily)" not in data:
|
| 83 |
if "Note" in data or "Information" in data:
|
| 84 |
+
raise HTTPException(429, "Alpha Vantage rate limit exceeded")
|
| 85 |
+
raise HTTPException(400, "Invalid stock ticker symbol")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
+
df = pd.DataFrame.from_dict(
|
| 88 |
+
data["Time Series (Daily)"], orient="index"
|
| 89 |
+
)
|
| 90 |
|
|
|
|
| 91 |
df.rename(
|
| 92 |
columns={
|
| 93 |
"1. open": "Open",
|
|
|
|
| 100 |
)
|
| 101 |
|
| 102 |
df = df[["Open", "High", "Low", "Close", "Volume"]]
|
| 103 |
+
df = df.apply(pd.to_numeric, errors="coerce").dropna()
|
|
|
|
| 104 |
|
| 105 |
df.index = pd.to_datetime(df.index)
|
| 106 |
df.sort_index(inplace=True)
|
| 107 |
|
| 108 |
if len(df) < settings.history_window + 50:
|
| 109 |
+
raise HTTPException(400, "Not enough historical stock data")
|
|
|
|
|
|
|
|
|
|
| 110 |
|
| 111 |
return df
|
| 112 |
|
|
|
|
| 115 |
# CRYPTO DATA
|
| 116 |
# -------------------------
|
| 117 |
def _fetch_crypto_history(ticker: str) -> pd.DataFrame:
|
|
|
|
| 118 |
if "-" in ticker:
|
| 119 |
+
symbol, market = ticker.split("-", 1)
|
| 120 |
else:
|
| 121 |
+
symbol, market = ticker, "USD"
|
| 122 |
|
| 123 |
params = {
|
| 124 |
"function": "DIGITAL_CURRENCY_DAILY",
|
| 125 |
+
"symbol": symbol.upper(),
|
| 126 |
+
"market": market.upper(),
|
| 127 |
"apikey": settings.alpha_vantage_api_key,
|
| 128 |
}
|
| 129 |
|
| 130 |
+
data = _safe_get_json(params)
|
|
|
|
| 131 |
|
| 132 |
if "Time Series (Digital Currency Daily)" not in data:
|
| 133 |
if "Note" in data or "Information" in data:
|
| 134 |
+
raise HTTPException(429, "Alpha Vantage rate limit exceeded")
|
| 135 |
+
raise HTTPException(400, "Invalid crypto ticker symbol")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
|
| 137 |
+
df = pd.DataFrame.from_dict(
|
| 138 |
+
data["Time Series (Digital Currency Daily)"], orient="index"
|
| 139 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
|
| 141 |
+
df = _detect_ohlcv_columns(df)
|
| 142 |
|
|
|
|
| 143 |
df = df.apply(pd.to_numeric, errors="coerce")
|
| 144 |
df.dropna(subset=["Open", "High", "Low", "Close"], inplace=True)
|
| 145 |
|
| 146 |
df.index = pd.to_datetime(df.index)
|
| 147 |
df.sort_index(inplace=True)
|
| 148 |
|
|
|
|
| 149 |
if len(df) < settings.history_window + 50:
|
| 150 |
+
raise HTTPException(400, "Not enough historical crypto data")
|
|
|
|
|
|
|
|
|
|
| 151 |
|
| 152 |
return df
|
| 153 |
|
| 154 |
|
|
|
|
| 155 |
# -------------------------
|
| 156 |
# Cached unified fetcher
|
| 157 |
# -------------------------
|
| 158 |
def fetch_raw_history(ticker: str) -> pd.DataFrame:
|
|
|
|
| 159 |
key = ticker.upper()
|
| 160 |
+
now = time.time()
|
| 161 |
|
| 162 |
+
if key in _CACHE and now - _CACHE[key]["ts"] < _CACHE_TTL:
|
| 163 |
+
return _CACHE[key]["df"]
|
|
|
|
|
|
|
| 164 |
|
| 165 |
+
df = (
|
| 166 |
+
_fetch_crypto_history(key)
|
| 167 |
+
if is_crypto(key)
|
| 168 |
+
else _fetch_stock_history(key)
|
| 169 |
+
)
|
| 170 |
|
| 171 |
_CACHE[key] = {"df": df, "ts": now}
|
| 172 |
return df
|
|
|
|
| 178 |
def get_enriched_history(ticker: str) -> Tuple[pd.DataFrame, pd.DataFrame]:
|
| 179 |
df_raw = fetch_raw_history(ticker)
|
| 180 |
df_tech = add_technical_indicators(df_raw)
|
| 181 |
+
|
| 182 |
+
if len(df_tech) < settings.history_window:
|
| 183 |
+
raise HTTPException(
|
| 184 |
+
status_code=400,
|
| 185 |
+
detail="Insufficient data after technical indicator calculation",
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
return df_raw, df_tech
|
| 189 |
|
| 190 |
|
| 191 |
def last_n_candles(df: pd.DataFrame, n: int) -> list[dict]:
|
|
|
|
| 192 |
return [
|
| 193 |
+
{
|
| 194 |
+
"date": idx.strftime("%Y-%m-%d"),
|
| 195 |
+
"price": float(row["Close"]),
|
| 196 |
+
}
|
| 197 |
+
for idx, row in df.tail(n).iterrows()
|
| 198 |
]
|