Miruzen commited on
Commit
687eaa2
·
verified ·
1 Parent(s): 6f60507

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +196 -147
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
- # Konfigurasi logging
9
- logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
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
- logging.debug(f"Memulai ema_manual untuk span={span}. Jumlah harga: {len(prices)}")
26
- if len(prices) < span:
27
- logging.warning(f"Tidak cukup data untuk menghitung EMA span {span}. Hanya {len(prices)} tersedia.")
28
- return [np.nan] * len(prices)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
- ema = [np.nan] * len(prices)
31
- alpha = 2 / (span + 1)
32
 
 
 
 
 
 
33
  try:
34
- for i in range(len(prices)):
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
- logging.error(f"Error dalam loop ema_manual (span={span}, index={i}): {e}", exc_info=True)
45
  raise
46
 
47
- logging.debug(f"ema_manual selesai untuk span={span}.")
48
- return ema
49
 
50
- def get_dynamic_minmax():
51
- today = datetime.now().date()
52
- start = today - timedelta(days=BASE_WINDOW)
53
- logging.info(f"Mengunduh data untuk min/max: start={start}, end={today + timedelta(days=1)}")
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
- logging.debug(f"Menganalisis tren untuk data terbaru: {latest_row.to_dict()}")
 
 
 
 
 
71
 
72
- ema20 = latest_row["EMA20"]
73
- ema50 = latest_row["EMA50"]
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
- logging.error("EMA50 adalah nol, tidak dapat menghitung gap persen.")
85
- diff = 0
86
  else:
87
- diff = abs(ema20 - ema50) / ema50 * 100
88
 
89
- if diff > 0.3:
90
  strength = "strong"
91
- elif diff > 0.1:
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(diff, 3)
109
  }
110
 
 
 
 
111
  @app.post("/analyze")
112
  def analyze_ema_endpoint(input_data: DateRange):
 
 
 
 
 
113
  try:
114
- logging.info(f"Menerima permintaan /analyze dengan start_date={input_data.start_date}, end_date={input_data.end_date}")
115
- start_date = pd.to_datetime(input_data.start_date)
116
- end_date = pd.to_datetime(input_data.end_date)
117
- extended_start = start_date - timedelta(days=60)
118
-
119
- logging.info(f"Mengunduh data dari yfinance: start={extended_start.strftime('%Y-%m-%d')}, end={end_date.strftime('%Y-%m-%d')}")
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
- close_min, close_max = get_dynamic_minmax()
145
- df["norm_close"] = df["close"].apply(lambda x: normalize_close(x, close_min, close_max))
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
- logging.error(f"Error TERTANGKAP di endpoint /analyze_ema_endpoint: {e}", exc_info=True)
168
- return {"status": "error", "message": str(e)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
 
170
  @app.post("/summary")
171
  def ema_summary_endpoint(input_data: DateRange):
 
 
 
172
  try:
173
- logging.info(f"Menerima permintaan /summary dengan start_date={input_data.start_date}, end_date={input_data.end_date}")
174
- start_date = pd.to_datetime(input_data.start_date)
175
- end_date = pd.to_datetime(input_data.end_date)
176
- extended_start = start_date - timedelta(days=60)
177
-
178
- df = yf.download(PAIR, start=extended_start, end=end_date + timedelta(days=1), auto_adjust=True)
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
- logging.error(f"Error TERTANGKAP di endpoint /ema_summary_endpoint: {e}", exc_info=True)
217
- return {"status": "error", "message": str(e)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 🚀"}