Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -1,13 +1,22 @@
|
|
|
|
|
| 1 |
from fastapi import FastAPI
|
| 2 |
from pydantic import BaseModel
|
| 3 |
import pandas as pd
|
| 4 |
import numpy as np
|
| 5 |
import yfinance as yf
|
| 6 |
from datetime import datetime, timedelta
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
app = FastAPI(
|
| 12 |
title="Model B – EMA & Dynamic Scaling API",
|
| 13 |
description="API untuk menghitung EMA, normalisasi, dan analisis tren otomatis berdasarkan data yfinance",
|
|
@@ -15,63 +24,83 @@ app = FastAPI(
|
|
| 15 |
)
|
| 16 |
|
| 17 |
PAIR = "EURUSD=X"
|
| 18 |
-
BASE_WINDOW = 60
|
| 19 |
|
|
|
|
|
|
|
|
|
|
| 20 |
class DateRange(BaseModel):
|
| 21 |
-
start_date: str
|
| 22 |
-
end_date: str
|
| 23 |
|
|
|
|
|
|
|
|
|
|
| 24 |
def ema_manual(prices, span):
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
-
ema
|
| 31 |
-
alpha = 2 / (span + 1)
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
try:
|
| 34 |
-
|
| 35 |
-
if i < span - 1:
|
| 36 |
-
ema[i] = np.nan
|
| 37 |
-
elif i == span - 1: # ✅ diperbaiki di sini
|
| 38 |
-
# EMA pertama = rata-rata N pertama
|
| 39 |
-
ema[i] = np.mean(prices[:span])
|
| 40 |
-
logging.debug(f"EMA awal (span={span}) dihitung: {ema[i]:.6f}")
|
| 41 |
-
else:
|
| 42 |
-
ema[i] = alpha * prices[i] + (1 - alpha) * ema[i - 1]
|
| 43 |
except Exception as e:
|
| 44 |
-
|
| 45 |
raise
|
| 46 |
|
| 47 |
-
|
| 48 |
-
|
| 49 |
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
df = yf.download(PAIR, start=start, end=today + timedelta(days=1), auto_adjust=True)
|
| 55 |
-
if df.empty:
|
| 56 |
-
logging.error("Gagal mengambil data harga terbaru untuk min/max.")
|
| 57 |
-
raise ValueError("Gagal mengambil data harga terbaru.")
|
| 58 |
-
close_min = df["Close"].min()
|
| 59 |
-
close_max = df["Close"].max()
|
| 60 |
-
logging.info(f"Min/Max Close: {close_min}/{close_max}")
|
| 61 |
-
return float(close_min), float(close_max)
|
| 62 |
|
| 63 |
def normalize_close(value, close_min, close_max):
|
| 64 |
if close_max == close_min:
|
| 65 |
-
logging.warning(f"Max_close ({close_max}) sama dengan min_close ({close_min}). Mengembalikan 0.5.")
|
| 66 |
return 0.5
|
| 67 |
-
return (value - close_min) / (close_max - close_min)
|
| 68 |
|
| 69 |
def analyze_trend(latest_row):
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
-
ema20
|
| 73 |
-
|
| 74 |
-
close = latest_row["close"]
|
| 75 |
|
| 76 |
if ema20 > ema50:
|
| 77 |
trend = "bullish"
|
|
@@ -80,15 +109,14 @@ def analyze_trend(latest_row):
|
|
| 80 |
else:
|
| 81 |
trend = "neutral"
|
| 82 |
|
| 83 |
-
if ema50 == 0:
|
| 84 |
-
|
| 85 |
-
diff = 0
|
| 86 |
else:
|
| 87 |
-
|
| 88 |
|
| 89 |
-
if
|
| 90 |
strength = "strong"
|
| 91 |
-
elif
|
| 92 |
strength = "moderate"
|
| 93 |
else:
|
| 94 |
strength = "weak"
|
|
@@ -100,122 +128,143 @@ def analyze_trend(latest_row):
|
|
| 100 |
else:
|
| 101 |
price_position = "between EMAs — indecision zone"
|
| 102 |
|
| 103 |
-
logging.debug(f"Analisis tren: trend={trend}, strength={strength}, pos={price_position}")
|
| 104 |
return {
|
| 105 |
"trend": trend,
|
| 106 |
"strength": strength,
|
| 107 |
"price_position": price_position,
|
| 108 |
-
"ema_gap_percent": round(
|
| 109 |
}
|
| 110 |
|
|
|
|
|
|
|
|
|
|
| 111 |
@app.post("/analyze")
|
| 112 |
def analyze_ema_endpoint(input_data: DateRange):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
try:
|
| 114 |
-
|
| 115 |
-
start_date = pd.to_datetime(input_data.start_date)
|
| 116 |
-
end_date = pd.to_datetime(input_data.end_date)
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
df = yf.download(PAIR, start=extended_start, end=end_date + timedelta(days=1), auto_adjust=True)
|
| 121 |
-
|
| 122 |
-
if df.empty:
|
| 123 |
-
logging.warning("Data tidak ditemukan untuk rentang tanggal yang diperluas.")
|
| 124 |
-
return {"status": "error", "message": "Data tidak ditemukan untuk rentang tanggal tersebut"}
|
| 125 |
-
|
| 126 |
-
df = df.reset_index()[["Date", "Close"]]
|
| 127 |
-
df.rename(columns={"Date": "date", "Close": "close"}, inplace=True)
|
| 128 |
-
logging.debug(f"Jumlah baris setelah download dan rename: {len(df)}")
|
| 129 |
-
|
| 130 |
-
if len(df) < 50:
|
| 131 |
-
logging.error(f"Data terlalu sedikit ({len(df)} hari). Butuh minimal 50 hari untuk EMA50.")
|
| 132 |
-
return {"status": "error", "message": f"Data terlalu sedikit ({len(df)} hari). Butuh minimal 50 hari untuk EMA50."}
|
| 133 |
-
|
| 134 |
-
logging.debug(f"Memanggil ema_manual untuk EMA20 dengan {len(df)} data.")
|
| 135 |
-
# BARIS INI YANG DIPERBAIKI:
|
| 136 |
-
df["EMA20"] = ema_manual(df["close"].values.tolist(), 20)
|
| 137 |
-
logging.debug(f"Memanggil ema_manual untuk EMA50 dengan {len(df)} data.")
|
| 138 |
-
# BARIS INI YANG DIPERBAIKI:
|
| 139 |
-
df["EMA50"] = ema_manual(df["close"].values.tolist(), 50)
|
| 140 |
-
|
| 141 |
-
df = df.dropna().reset_index(drop=True)
|
| 142 |
-
logging.debug(f"Jumlah baris setelah dropna: {len(df)}")
|
| 143 |
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
chart_data = {
|
| 148 |
-
"dates": df["date"].dt.strftime("%Y-%m-%d").tolist(),
|
| 149 |
-
"close": df["close"].round(6).tolist(),
|
| 150 |
-
"EMA20": df["EMA20"].round(6).tolist(),
|
| 151 |
-
"EMA50": df["EMA50"].round(6).tolist(),
|
| 152 |
-
"norm_close": df["norm_close"].round(6).tolist(),
|
| 153 |
-
"min_close": float(close_min),
|
| 154 |
-
"max_close": float(close_max),
|
| 155 |
-
}
|
| 156 |
-
logging.info(f"Analisis /analyze berhasil, {len(df)} data point.")
|
| 157 |
-
return {
|
| 158 |
-
"status": "ok",
|
| 159 |
-
"pair": PAIR,
|
| 160 |
-
"start_date": str(start_date.date()),
|
| 161 |
-
"end_date": str(end_date.date()),
|
| 162 |
-
"data_points": len(df),
|
| 163 |
-
"chart_data": chart_data
|
| 164 |
-
}
|
| 165 |
|
|
|
|
|
|
|
| 166 |
except Exception as e:
|
| 167 |
-
|
| 168 |
-
return {"status": "error", "message":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
|
| 170 |
@app.post("/summary")
|
| 171 |
def ema_summary_endpoint(input_data: DateRange):
|
|
|
|
|
|
|
|
|
|
| 172 |
try:
|
| 173 |
-
|
| 174 |
-
start_date = pd.to_datetime(input_data.start_date)
|
| 175 |
-
end_date = pd.to_datetime(input_data.end_date)
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
if df.empty:
|
| 180 |
-
logging.warning("Data tidak ditemukan untuk rentang tanggal yang diperluas.")
|
| 181 |
-
return {"status": "error", "message": "Data tidak ditemukan"}
|
| 182 |
-
|
| 183 |
-
df = df.reset_index()[["Date", "Close"]]
|
| 184 |
-
df.rename(columns={"Date": "date", "Close": "close"}, inplace=True)
|
| 185 |
-
logging.debug(f"Jumlah baris setelah download dan rename: {len(df)}")
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
if len(df) < 50:
|
| 189 |
-
logging.error(f"Data terlalu sedikit ({len(df)} hari). Butuh minimal 50 hari untuk EMA50.")
|
| 190 |
-
return {"status": "error", "message": f"Data terlalu sedikit ({len(df)} hari). Butuh minimal 50 hari untuk EMA50."}
|
| 191 |
-
|
| 192 |
-
logging.debug(f"Memanggil ema_manual untuk EMA20 dengan {len(df)} data.")
|
| 193 |
-
# BARIS INI YANG DIPERBAIKI:
|
| 194 |
-
df["EMA20"] = ema_manual(df["close"].values.tolist(), 20)
|
| 195 |
-
logging.debug(f"Memanggil ema_manual untuk EMA50 dengan {len(df)} data.")
|
| 196 |
-
# BARIS INI YANG DIPERBAIKI:
|
| 197 |
-
df["EMA50"] = ema_manual(df["close"].values.tolist(), 50)
|
| 198 |
-
df = df.dropna().reset_index(drop=True)
|
| 199 |
-
logging.debug(f"Jumlah baris setelah dropna: {len(df)}")
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
latest = df.iloc[-1]
|
| 203 |
-
analysis = analyze_trend(latest)
|
| 204 |
-
logging.info(f"Analisis /summary berhasil, tanggal terakhir: {latest['date'].strftime('%Y-%m-%d')}")
|
| 205 |
-
return {
|
| 206 |
-
"status": "ok",
|
| 207 |
-
"pair": PAIR,
|
| 208 |
-
"as_of_date": latest["date"].strftime("%Y-%m-%d"),
|
| 209 |
-
"close": round(float(latest["close"]), 6),
|
| 210 |
-
"EMA20": round(float(latest["EMA20"]), 6),
|
| 211 |
-
"EMA50": round(float(latest["EMA50"]), 6),
|
| 212 |
-
"trend_analysis": analysis
|
| 213 |
-
}
|
| 214 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
except Exception as e:
|
| 216 |
-
|
| 217 |
-
return {"status": "error", "message":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
|
| 219 |
@app.get("/")
|
| 220 |
def root():
|
| 221 |
-
return {"message": "Model B API (EMA + Trend Summary) aktif 🚀"}
|
|
|
|
| 1 |
+
# app.py
|
| 2 |
from fastapi import FastAPI
|
| 3 |
from pydantic import BaseModel
|
| 4 |
import pandas as pd
|
| 5 |
import numpy as np
|
| 6 |
import yfinance as yf
|
| 7 |
from datetime import datetime, timedelta
|
| 8 |
+
import logging
|
| 9 |
+
import os
|
| 10 |
+
|
| 11 |
+
# -----------------------
|
| 12 |
+
# Logging
|
| 13 |
+
# -----------------------
|
| 14 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s — %(levelname)s — %(message)s")
|
| 15 |
+
logger = logging.getLogger("modelb_api")
|
| 16 |
+
|
| 17 |
+
# -----------------------
|
| 18 |
+
# App & config
|
| 19 |
+
# -----------------------
|
| 20 |
app = FastAPI(
|
| 21 |
title="Model B – EMA & Dynamic Scaling API",
|
| 22 |
description="API untuk menghitung EMA, normalisasi, dan analisis tren otomatis berdasarkan data yfinance",
|
|
|
|
| 24 |
)
|
| 25 |
|
| 26 |
PAIR = "EURUSD=X"
|
| 27 |
+
BASE_WINDOW = 60 # jumlah hari historical yang diambil untuk jaga-jaga agar EMA50 bisa dihitung
|
| 28 |
|
| 29 |
+
# -----------------------
|
| 30 |
+
# Request schema
|
| 31 |
+
# -----------------------
|
| 32 |
class DateRange(BaseModel):
|
| 33 |
+
start_date: str # ISO date 'YYYY-MM-DD'
|
| 34 |
+
end_date: str # ISO date 'YYYY-MM-DD'
|
| 35 |
|
| 36 |
+
# -----------------------
|
| 37 |
+
# Utility functions
|
| 38 |
+
# -----------------------
|
| 39 |
def ema_manual(prices, span):
|
| 40 |
+
"""
|
| 41 |
+
Manual EMA calculation:
|
| 42 |
+
- prices: iterable numeric
|
| 43 |
+
- span: int (e.g. 20 or 50)
|
| 44 |
+
Returns list of same length; first (span-1) entries are NaN, entry at index span-1 is SMA seed.
|
| 45 |
+
"""
|
| 46 |
+
prices = np.asarray(prices, dtype=float)
|
| 47 |
+
n = len(prices)
|
| 48 |
+
if n == 0:
|
| 49 |
+
return [np.nan] * 0
|
| 50 |
+
if span <= 0:
|
| 51 |
+
raise ValueError("span must be > 0")
|
| 52 |
+
ema = [np.nan] * n
|
| 53 |
+
alpha = 2.0 / (span + 1.0)
|
| 54 |
+
|
| 55 |
+
# Not enough data -> return NaNs (keputusan: tetap mengembalikan NaN untuk indeks sebelum span-1)
|
| 56 |
+
if n < span:
|
| 57 |
+
logger.warning(f"Not enough data for EMA span={span} (have {n} < needed {span}), returning NaNs.")
|
| 58 |
+
return ema
|
| 59 |
+
|
| 60 |
+
# seed with SMA at index span-1
|
| 61 |
+
seed = float(np.mean(prices[:span]))
|
| 62 |
+
ema[span - 1] = seed
|
| 63 |
+
|
| 64 |
+
# recursive EMA
|
| 65 |
+
for i in range(span, n):
|
| 66 |
+
ema[i] = alpha * prices[i] + (1.0 - alpha) * ema[i - 1]
|
| 67 |
|
| 68 |
+
return ema
|
|
|
|
| 69 |
|
| 70 |
+
def get_dynamic_minmax(pair=PAIR, window_days=BASE_WINDOW):
|
| 71 |
+
"""Download recent window and return min/max of Close to use for normalization."""
|
| 72 |
+
today = datetime.utcnow().date()
|
| 73 |
+
start = today - timedelta(days=window_days)
|
| 74 |
+
logger.info(f"Fetching recent data for dynamic min/max: {start} -> {today}")
|
| 75 |
try:
|
| 76 |
+
df = yf.download(pair, start=start, end=today + timedelta(days=1), auto_adjust=True, progress=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
except Exception as e:
|
| 78 |
+
logger.error("yfinance download failed for dynamic minmax: %s", e, exc_info=True)
|
| 79 |
raise
|
| 80 |
|
| 81 |
+
if df is None or df.empty:
|
| 82 |
+
raise ValueError("Failed to fetch recent prices for dynamic min/max")
|
| 83 |
|
| 84 |
+
close_min = float(df["Close"].min())
|
| 85 |
+
close_max = float(df["Close"].max())
|
| 86 |
+
logger.info("dynamic min/max: %s / %s", close_min, close_max)
|
| 87 |
+
return close_min, close_max
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
|
| 89 |
def normalize_close(value, close_min, close_max):
|
| 90 |
if close_max == close_min:
|
|
|
|
| 91 |
return 0.5
|
| 92 |
+
return float((value - close_min) / (close_max - close_min))
|
| 93 |
|
| 94 |
def analyze_trend(latest_row):
|
| 95 |
+
"""
|
| 96 |
+
Return simple analysis dict based on EMA20 vs EMA50 and gap percent.
|
| 97 |
+
"""
|
| 98 |
+
ema20 = latest_row.get("EMA20", np.nan)
|
| 99 |
+
ema50 = latest_row.get("EMA50", np.nan)
|
| 100 |
+
close = latest_row.get("close", np.nan)
|
| 101 |
|
| 102 |
+
if np.isnan(ema20) or np.isnan(ema50):
|
| 103 |
+
return {"trend": "unknown", "strength": "unknown", "price_position": "unknown", "ema_gap_percent": None}
|
|
|
|
| 104 |
|
| 105 |
if ema20 > ema50:
|
| 106 |
trend = "bullish"
|
|
|
|
| 109 |
else:
|
| 110 |
trend = "neutral"
|
| 111 |
|
| 112 |
+
if ema50 == 0 or np.isnan(ema50):
|
| 113 |
+
gap_pct = 0.0
|
|
|
|
| 114 |
else:
|
| 115 |
+
gap_pct = abs(ema20 - ema50) / abs(ema50) * 100.0
|
| 116 |
|
| 117 |
+
if gap_pct > 0.3:
|
| 118 |
strength = "strong"
|
| 119 |
+
elif gap_pct > 0.1:
|
| 120 |
strength = "moderate"
|
| 121 |
else:
|
| 122 |
strength = "weak"
|
|
|
|
| 128 |
else:
|
| 129 |
price_position = "between EMAs — indecision zone"
|
| 130 |
|
|
|
|
| 131 |
return {
|
| 132 |
"trend": trend,
|
| 133 |
"strength": strength,
|
| 134 |
"price_position": price_position,
|
| 135 |
+
"ema_gap_percent": round(gap_pct, 4)
|
| 136 |
}
|
| 137 |
|
| 138 |
+
# -----------------------
|
| 139 |
+
# Endpoints
|
| 140 |
+
# -----------------------
|
| 141 |
@app.post("/analyze")
|
| 142 |
def analyze_ema_endpoint(input_data: DateRange):
|
| 143 |
+
"""
|
| 144 |
+
Return time series data (date, close, EMA20, EMA50, norm_close) for plotting.
|
| 145 |
+
If requested window is too short to compute EMA50, endpoint automatically uses earlier data
|
| 146 |
+
by extending start_date backward by BASE_WINDOW days.
|
| 147 |
+
"""
|
| 148 |
try:
|
| 149 |
+
logger.info("Received /analyze request: %s -> %s", input_data.start_date, input_data.end_date)
|
| 150 |
+
start_date = pd.to_datetime(input_data.start_date).date()
|
| 151 |
+
end_date = pd.to_datetime(input_data.end_date).date()
|
| 152 |
+
except Exception as e:
|
| 153 |
+
logger.error("Invalid date format: %s", e)
|
| 154 |
+
return {"status": "error", "message": "Invalid date format. Use YYYY-MM-DD."}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
|
| 156 |
+
# extend start to ensure enough history (for EMA50)
|
| 157 |
+
extended_start = start_date - timedelta(days=BASE_WINDOW)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
|
| 159 |
+
try:
|
| 160 |
+
df_raw = yf.download(PAIR, start=extended_start, end=end_date + timedelta(days=1), auto_adjust=True, progress=False)
|
| 161 |
except Exception as e:
|
| 162 |
+
logger.error("yfinance download failed: %s", e, exc_info=True)
|
| 163 |
+
return {"status": "error", "message": f"Failed to fetch data from yfinance: {e}"}
|
| 164 |
+
|
| 165 |
+
if df_raw is None or df_raw.empty:
|
| 166 |
+
logger.warning("No data returned from yfinance for the requested range")
|
| 167 |
+
return {"status": "error", "message": "No price data for requested dates"}
|
| 168 |
+
|
| 169 |
+
df = df_raw.reset_index()[["Date", "Close"]].rename(columns={"Date": "date", "Close": "close"})
|
| 170 |
+
df["date"] = pd.to_datetime(df["date"]).dt.normalize()
|
| 171 |
+
logger.info("Downloaded rows: %d", len(df))
|
| 172 |
+
|
| 173 |
+
if len(df) < 50:
|
| 174 |
+
msg = f"Insufficient data points after extension ({len(df)}). Need at least 50 for EMA50."
|
| 175 |
+
logger.error(msg)
|
| 176 |
+
return {"status": "error", "message": msg}
|
| 177 |
+
|
| 178 |
+
# compute EMAs
|
| 179 |
+
close_list = df["close"].tolist()
|
| 180 |
+
df["EMA20"] = ema_manual(close_list, 20)
|
| 181 |
+
df["EMA50"] = ema_manual(close_list, 50)
|
| 182 |
+
|
| 183 |
+
# drop rows where EMA values are NaN (i.e., before we have enough seed)
|
| 184 |
+
df = df.dropna(subset=["EMA20", "EMA50"]).reset_index(drop=True)
|
| 185 |
+
if df.empty:
|
| 186 |
+
return {"status": "error", "message": "After computing EMAs no usable rows remain."}
|
| 187 |
+
|
| 188 |
+
# dynamic min/max and normalization
|
| 189 |
+
try:
|
| 190 |
+
close_min, close_max = get_dynamic_minmax()
|
| 191 |
+
except Exception as e:
|
| 192 |
+
logger.warning("Dynamic min/max fetch failed: %s", e)
|
| 193 |
+
close_min, close_max = float(df["close"].min()), float(df["close"].max())
|
| 194 |
+
|
| 195 |
+
df["norm_close"] = df["close"].apply(lambda x: normalize_close(x, close_min, close_max))
|
| 196 |
+
|
| 197 |
+
chart_data = {
|
| 198 |
+
"dates": df["date"].dt.strftime("%Y-%m-%d").tolist(),
|
| 199 |
+
"close": [float(x) for x in df["close"].tolist()],
|
| 200 |
+
"ema20": [float(x) for x in df["EMA20"].tolist()],
|
| 201 |
+
"ema50": [float(x) for x in df["EMA50"].tolist()],
|
| 202 |
+
"norm_close": [float(x) for x in df["norm_close"].tolist()],
|
| 203 |
+
"min_close": float(close_min),
|
| 204 |
+
"max_close": float(close_max),
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
logger.info("Analyze success -> points: %d", len(df))
|
| 208 |
+
return {
|
| 209 |
+
"status": "ok",
|
| 210 |
+
"pair": PAIR,
|
| 211 |
+
"requested_start": str(start_date),
|
| 212 |
+
"requested_end": str(end_date),
|
| 213 |
+
"data_points": len(df),
|
| 214 |
+
"chart_data": chart_data
|
| 215 |
+
}
|
| 216 |
|
| 217 |
@app.post("/summary")
|
| 218 |
def ema_summary_endpoint(input_data: DateRange):
|
| 219 |
+
"""
|
| 220 |
+
Return last available close, EMA20, EMA50 and a short trend analysis dictionary.
|
| 221 |
+
"""
|
| 222 |
try:
|
| 223 |
+
logger.info("Received /summary request: %s -> %s", input_data.start_date, input_data.end_date)
|
| 224 |
+
start_date = pd.to_datetime(input_data.start_date).date()
|
| 225 |
+
end_date = pd.to_datetime(input_data.end_date).date()
|
| 226 |
+
except Exception as e:
|
| 227 |
+
logger.error("Invalid date input for /summary: %s", e)
|
| 228 |
+
return {"status": "error", "message": "Invalid date format. Use YYYY-MM-DD."}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
|
| 230 |
+
extended_start = start_date - timedelta(days=BASE_WINDOW)
|
| 231 |
+
|
| 232 |
+
try:
|
| 233 |
+
df_raw = yf.download(PAIR, start=extended_start, end=end_date + timedelta(days=1), auto_adjust=True, progress=False)
|
| 234 |
except Exception as e:
|
| 235 |
+
logger.error("yfinance download failed for /summary: %s", e, exc_info=True)
|
| 236 |
+
return {"status": "error", "message": f"Failed to fetch data from yfinance: {e}"}
|
| 237 |
+
|
| 238 |
+
if df_raw is None or df_raw.empty:
|
| 239 |
+
return {"status": "error", "message": "No price data for requested dates"}
|
| 240 |
+
|
| 241 |
+
df = df_raw.reset_index()[["Date", "Close"]].rename(columns={"Date": "date", "Close": "close"})
|
| 242 |
+
df["date"] = pd.to_datetime(df["date"]).dt.normalize()
|
| 243 |
+
|
| 244 |
+
if len(df) < 50:
|
| 245 |
+
return {"status": "error", "message": f"Insufficient data (have {len(df)} rows). Need >=50 for EMA50."}
|
| 246 |
+
|
| 247 |
+
close_list = df["close"].tolist()
|
| 248 |
+
df["EMA20"] = ema_manual(close_list, 20)
|
| 249 |
+
df["EMA50"] = ema_manual(close_list, 50)
|
| 250 |
+
df = df.dropna(subset=["EMA20", "EMA50"]).reset_index(drop=True)
|
| 251 |
+
|
| 252 |
+
if df.empty:
|
| 253 |
+
return {"status": "error", "message": "No usable rows after EMA computation."}
|
| 254 |
+
|
| 255 |
+
latest = df.iloc[-1]
|
| 256 |
+
analysis = analyze_trend(latest)
|
| 257 |
+
|
| 258 |
+
return {
|
| 259 |
+
"status": "ok",
|
| 260 |
+
"pair": PAIR,
|
| 261 |
+
"as_of_date": latest["date"].strftime("%Y-%m-%d"),
|
| 262 |
+
"close": float(latest["close"]),
|
| 263 |
+
"ema20": float(latest["EMA20"]),
|
| 264 |
+
"ema50": float(latest["EMA50"]),
|
| 265 |
+
"trend_analysis": analysis
|
| 266 |
+
}
|
| 267 |
|
| 268 |
@app.get("/")
|
| 269 |
def root():
|
| 270 |
+
return {"message": "Model B API (EMA + Trend Summary) aktif 🚀"}
|