Miruzen commited on
Commit
67b5ff4
Β·
verified Β·
1 Parent(s): 8bd360d

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +161 -233
app.py CHANGED
@@ -1,4 +1,8 @@
1
- # app.py
 
 
 
 
2
  from fastapi import FastAPI
3
  from pydantic import BaseModel
4
  import pandas as pd
@@ -6,149 +10,122 @@ 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",
23
- version="2.1"
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
- def clean_yf_csv(df_raw):
 
 
 
37
  """
38
- Membersihkan struktur CSV seperti hasil export yfinance:
39
- - baris pertama: nama kolom
40
- - baris kedua: ticker
41
- - baris ketiga: header date kosong
42
- - sisakan hanya kolom ['date', 'close']
43
  """
44
- # Jika baris pertama bukan data (mengandung "Price,Close,High,..."), kita re-parse manual
45
- if "Price" in df_raw.columns[0]:
46
- logger.info("Detected raw yfinance export format, cleaning...")
47
- # Baca ulang file tanpa header dulu
48
- df_raw.columns = [c.strip().lower() for c in df_raw.columns]
49
- # Hapus baris ke-2 dan ke-3
50
- df_raw = df_raw.drop(index=[0, 1])
51
- # Ganti kolom 'price' menjadi 'date'
52
- df_raw = df_raw.rename(columns={"price": "date"})
53
- # Pastikan hanya ambil kolom 'date' dan 'close'
54
- keep_cols = [c for c in df_raw.columns if c in ["date", "close"]]
55
- df_raw = df_raw[keep_cols]
56
- # Ubah tipe data tanggal
57
- df_raw["date"] = pd.to_datetime(df_raw["date"], errors="coerce")
58
- df_raw = df_raw.dropna(subset=["date"])
59
- df_raw["close"] = pd.to_numeric(df_raw["close"], errors="coerce")
60
- df_raw = df_raw.dropna(subset=["close"])
61
- df_raw = df_raw.reset_index(drop=True)
62
- return df_raw
63
 
64
- def load_yf_data(pair, start, end):
65
- """Download yfinance data and ensure 'date' and 'close' columns exist."""
66
- df_raw = yf.download(pair, start=start, end=end, auto_adjust=True, progress=False)
67
- if df_raw.empty:
68
- raise ValueError("No data from yfinance for that range.")
69
 
70
- # Jika data CSV seperti contoh kamu (ada baris Ticker dan Price)
71
- if "Price" in df_raw.columns or "Ticker" in df_raw.columns:
72
- df_raw = clean_yf_csv(df_raw)
73
- return df_raw
74
 
75
- # flatten MultiIndex columns
76
- if isinstance(df_raw.columns, pd.MultiIndex):
77
- df_raw.columns = df_raw.columns.get_level_values(0)
 
78
 
79
- df = df_raw.reset_index()[["Date", "Close"]].rename(columns={"Date": "date", "Close": "close"})
80
- df["date"] = pd.to_datetime(df["date"]).dt.normalize()
81
- return df
 
 
82
 
 
 
83
 
84
- # -----------------------
85
- # Utility functions
86
- # -----------------------
 
 
 
 
 
87
  def ema_manual(prices, span):
88
- """
89
- Manual EMA calculation:
90
- - prices: iterable numeric
91
- - span: int (e.g. 20 or 50)
92
- Returns list of same length; first (span-1) entries are NaN, entry at index span-1 is SMA seed.
93
- """
94
- prices = np.asarray(prices, dtype=float)
95
- n = len(prices)
96
- if n == 0:
97
- return [np.nan] * 0
98
- if span <= 0:
99
- raise ValueError("span must be > 0")
100
- ema = [np.nan] * n
101
- alpha = 2.0 / (span + 1.0)
102
-
103
- # Not enough data -> return NaNs (keputusan: tetap mengembalikan NaN untuk indeks sebelum span-1)
104
- if n < span:
105
- logger.warning(f"Not enough data for EMA span={span} (have {n} < needed {span}), returning NaNs.")
106
- return ema
107
-
108
- # seed with SMA at index span-1
109
- seed = float(np.mean(prices[:span]))
110
- ema[span - 1] = seed
111
-
112
- # recursive EMA
113
- for i in range(span, n):
114
- ema[i] = alpha * prices[i] + (1.0 - alpha) * ema[i - 1]
115
 
116
  return ema
117
 
118
- def get_dynamic_minmax(pair=PAIR, window_days=BASE_WINDOW):
119
- """Download recent window and return min/max of Close to use for normalization."""
120
- today = datetime.utcnow().date()
121
- start = today - timedelta(days=window_days)
122
- logger.info(f"Fetching recent data for dynamic min/max: {start} -> {today}")
123
- try:
124
- df = yf.download(pair, start=start, end=today + timedelta(days=1), auto_adjust=True, progress=False)
125
- except Exception as e:
126
- logger.error("yfinance download failed for dynamic minmax: %s", e, exc_info=True)
127
- raise
128
 
129
- if df is None or df.empty:
130
- raise ValueError("Failed to fetch recent prices for dynamic min/max")
 
 
 
 
 
 
 
 
 
131
 
132
- close_min = float(df["Close"].min())
133
- close_max = float(df["Close"].max())
134
- logger.info("dynamic min/max: %s / %s", close_min, close_max)
135
- return close_min, close_max
136
 
137
  def normalize_close(value, close_min, close_max):
138
  if close_max == close_min:
139
  return 0.5
140
- return float((value - close_min) / (close_max - close_min))
141
 
142
- def analyze_trend(latest_row):
143
- """
144
- Return simple analysis dict based on EMA20 vs EMA50 and gap percent.
145
- """
146
- ema20 = latest_row.get("EMA20", np.nan)
147
- ema50 = latest_row.get("EMA50", np.nan)
148
- close = latest_row.get("close", np.nan)
149
 
150
- if np.isnan(ema20) or np.isnan(ema50):
151
- return {"trend": "unknown", "strength": "unknown", "price_position": "unknown", "ema_gap_percent": None}
 
 
 
 
 
152
 
153
  if ema20 > ema50:
154
  trend = "bullish"
@@ -157,14 +134,10 @@ def analyze_trend(latest_row):
157
  else:
158
  trend = "neutral"
159
 
160
- if ema50 == 0 or np.isnan(ema50):
161
- gap_pct = 0.0
162
- else:
163
- gap_pct = abs(ema20 - ema50) / abs(ema50) * 100.0
164
-
165
- if gap_pct > 0.3:
166
  strength = "strong"
167
- elif gap_pct > 0.1:
168
  strength = "moderate"
169
  else:
170
  strength = "weak"
@@ -180,139 +153,94 @@ def analyze_trend(latest_row):
180
  "trend": trend,
181
  "strength": strength,
182
  "price_position": price_position,
183
- "ema_gap_percent": round(gap_pct, 4)
184
  }
185
 
186
- # -----------------------
187
- # Endpoints
188
- # -----------------------
 
189
  @app.post("/analyze")
190
  def analyze_ema_endpoint(input_data: DateRange):
191
- """
192
- Return time series data (date, close, EMA20, EMA50, norm_close) for plotting.
193
- If requested window is too short to compute EMA50, endpoint automatically uses earlier data
194
- by extending start_date backward by BASE_WINDOW days.
195
- """
196
  try:
197
- logger.info("Received /analyze request: %s -> %s", input_data.start_date, input_data.end_date)
198
- start_date = pd.to_datetime(input_data.start_date).date()
199
- end_date = pd.to_datetime(input_data.end_date).date()
200
- except Exception as e:
201
- logger.error("Invalid date format: %s", e)
202
- return {"status": "error", "message": "Invalid date format. Use YYYY-MM-DD."}
203
 
204
- # extend start to ensure enough history (for EMA50)
205
- extended_start = start_date - timedelta(days=BASE_WINDOW)
 
206
 
207
- try:
208
- df_raw = yf.download(PAIR, start=extended_start, end=end_date + timedelta(days=1), auto_adjust=True, progress=False)
209
- except Exception as e:
210
- logger.error("yfinance download failed: %s", e, exc_info=True)
211
- return {"status": "error", "message": f"Failed to fetch data from yfinance: {e}"}
212
-
213
- if df_raw is None or df_raw.empty:
214
- logger.warning("No data returned from yfinance for the requested range")
215
- return {"status": "error", "message": "No price data for requested dates"}
216
-
217
- df = df_raw.reset_index()[["Date", "Close"]].rename(columns={"Date": "date", "Close": "close"})
218
- df["date"] = pd.to_datetime(df["date"]).dt.normalize()
219
- logger.info("Downloaded rows: %d", len(df))
220
-
221
- if len(df) < 50:
222
- msg = f"Insufficient data points after extension ({len(df)}). Need at least 50 for EMA50."
223
- logger.error(msg)
224
- return {"status": "error", "message": msg}
225
-
226
- # compute EMAs
227
- close_list = df["close"].tolist()
228
- df["EMA20"] = ema_manual(close_list, 20)
229
- df["EMA50"] = ema_manual(close_list, 50)
230
-
231
- # drop rows where EMA values are NaN (i.e., before we have enough seed)
232
- df = df.dropna(subset=["EMA20", "EMA50"]).reset_index(drop=True)
233
- if df.empty:
234
- return {"status": "error", "message": "After computing EMAs no usable rows remain."}
235
 
236
- # dynamic min/max and normalization
237
- try:
238
  close_min, close_max = get_dynamic_minmax()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
239
  except Exception as e:
240
- logger.warning("Dynamic min/max fetch failed: %s", e)
241
- close_min, close_max = float(df["close"].min()), float(df["close"].max())
242
-
243
- df["norm_close"] = df["close"].apply(lambda x: normalize_close(x, close_min, close_max))
244
-
245
- chart_data = {
246
- "dates": df["date"].dt.strftime("%Y-%m-%d").tolist(),
247
- "close": [float(x) for x in df["close"].tolist()],
248
- "ema20": [float(x) for x in df["EMA20"].tolist()],
249
- "ema50": [float(x) for x in df["EMA50"].tolist()],
250
- "norm_close": [float(x) for x in df["norm_close"].tolist()],
251
- "min_close": float(close_min),
252
- "max_close": float(close_max),
253
- }
254
 
255
- logger.info("Analyze success -> points: %d", len(df))
256
- return {
257
- "status": "ok",
258
- "pair": PAIR,
259
- "requested_start": str(start_date),
260
- "requested_end": str(end_date),
261
- "data_points": len(df),
262
- "chart_data": chart_data
263
- }
264
 
 
 
 
265
  @app.post("/summary")
266
  def ema_summary_endpoint(input_data: DateRange):
267
- """
268
- Return last available close, EMA20, EMA50 and a short trend analysis dictionary.
269
- """
270
  try:
271
- logger.info("Received /summary request: %s -> %s", input_data.start_date, input_data.end_date)
272
- start_date = pd.to_datetime(input_data.start_date).date()
273
- end_date = pd.to_datetime(input_data.end_date).date()
274
- except Exception as e:
275
- logger.error("Invalid date input for /summary: %s", e)
276
- return {"status": "error", "message": "Invalid date format. Use YYYY-MM-DD."}
277
-
278
- extended_start = start_date - timedelta(days=BASE_WINDOW)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
 
280
- try:
281
- df_raw = yf.download(PAIR, start=extended_start, end=end_date + timedelta(days=1), auto_adjust=True, progress=False)
282
  except Exception as e:
283
- logger.error("yfinance download failed for /summary: %s", e, exc_info=True)
284
- return {"status": "error", "message": f"Failed to fetch data from yfinance: {e}"}
285
-
286
- if df_raw is None or df_raw.empty:
287
- return {"status": "error", "message": "No price data for requested dates"}
288
-
289
- df = df_raw.reset_index()[["Date", "Close"]].rename(columns={"Date": "date", "Close": "close"})
290
- df["date"] = pd.to_datetime(df["date"]).dt.normalize()
291
 
292
- if len(df) < 50:
293
- return {"status": "error", "message": f"Insufficient data (have {len(df)} rows). Need >=50 for EMA50."}
294
-
295
- close_list = df["close"].tolist()
296
- df["EMA20"] = ema_manual(close_list, 20)
297
- df["EMA50"] = ema_manual(close_list, 50)
298
- df = df.dropna(subset=["EMA20", "EMA50"]).reset_index(drop=True)
299
-
300
- if df.empty:
301
- return {"status": "error", "message": "No usable rows after EMA computation."}
302
-
303
- latest = df.iloc[-1]
304
- analysis = analyze_trend(latest)
305
-
306
- return {
307
- "status": "ok",
308
- "pair": PAIR,
309
- "as_of_date": latest["date"].strftime("%Y-%m-%d"),
310
- "close": float(latest["close"]),
311
- "ema20": float(latest["EMA20"]),
312
- "ema50": float(latest["EMA50"]),
313
- "trend_analysis": analysis
314
- }
315
 
 
 
 
316
  @app.get("/")
317
  def root():
318
  return {"message": "Model B API (EMA + Trend Summary) aktif πŸš€"}
 
1
+ # ===============================================================
2
+ # Forex EMA + Dynamic Normalization API (Model B)
3
+ # Versi Final – Aman untuk Hugging Face Spaces (tanpa cache)
4
+ # ===============================================================
5
+
6
  from fastapi import FastAPI
7
  from pydantic import BaseModel
8
  import pandas as pd
 
10
  import yfinance as yf
11
  from datetime import datetime, timedelta
12
  import logging
13
+ import tempfile
14
  import os
15
 
16
+ # ===============================================================
17
+ # Konfigurasi Logging
18
+ # ===============================================================
19
+ logging.basicConfig(
20
+ level=logging.INFO,
21
+ format="%(asctime)s β€” %(levelname)s β€” %(message)s"
22
+ )
23
+ logger = logging.getLogger(__name__)
24
 
25
+ # ===============================================================
26
+ # Konfigurasi FastAPI
27
+ # ===============================================================
28
  app = FastAPI(
29
  title="Model B – EMA & Dynamic Scaling API",
30
  description="API untuk menghitung EMA, normalisasi, dan analisis tren otomatis berdasarkan data yfinance",
31
+ version="2.3"
32
  )
33
 
34
  PAIR = "EURUSD=X"
35
+ BASE_WINDOW = 60
36
 
37
+ # Matikan cache yfinance agar tidak menulis ke /.cache
38
+ os.environ["YFINANCE_CACHE_DISABLE"] = "1"
39
+ os.environ["YFINANCE_NO_TZ_CACHE"] = "1"
40
+ yf.set_tz_cache_location(tempfile.gettempdir())
41
+
42
+ # ===============================================================
43
+ # Schema
44
+ # ===============================================================
45
  class DateRange(BaseModel):
46
+ start_date: str
47
+ end_date: str
48
+
49
 
50
+ # ===============================================================
51
+ # Helper Function – Load data langsung dari yfinance
52
+ # ===============================================================
53
+ def load_yf_data(pair, start, end):
54
  """
55
+ Ambil data yfinance tanpa cache, hanya return kolom ['date', 'close'].
 
 
 
 
56
  """
57
+ try:
58
+ df = yf.download(pair, start=start, end=end, auto_adjust=True, progress=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
 
60
+ if df.empty:
61
+ raise ValueError("YFinance gagal mengambil data, data kosong.")
 
 
 
62
 
63
+ # Jika MultiIndex, ambil level pertama
64
+ if isinstance(df.columns, pd.MultiIndex):
65
+ df.columns = df.columns.get_level_values(0)
 
66
 
67
+ # Ambil hanya kolom Close
68
+ close_col = [c for c in df.columns if "close" in c.lower()]
69
+ if not close_col:
70
+ raise ValueError(f"Tidak ada kolom 'Close' ditemukan di {df.columns.tolist()}")
71
 
72
+ df = df.reset_index()[["Date", close_col[0]]]
73
+ df.columns = ["date", "close"]
74
+ df["date"] = pd.to_datetime(df["date"]).dt.date
75
+ df["close"] = pd.to_numeric(df["close"], errors="coerce")
76
+ df = df.dropna(subset=["close"]).reset_index(drop=True)
77
 
78
+ logger.info(f"βœ… Berhasil ambil data {len(df)} baris dari {pair}")
79
+ return df
80
 
81
+ except Exception as e:
82
+ logger.error(f"❌ Error load_yf_data(): {e}", exc_info=True)
83
+ raise ValueError(str(e))
84
+
85
+
86
+ # ===============================================================
87
+ # Helper Function – Manual EMA
88
+ # ===============================================================
89
  def ema_manual(prices, span):
90
+ if len(prices) < span:
91
+ return [np.nan] * len(prices)
92
+
93
+ ema = [np.nan] * len(prices)
94
+ alpha = 2 / (span + 1)
95
+ ema[span - 1] = np.mean(prices[:span])
96
+
97
+ for i in range(span, len(prices)):
98
+ ema[i] = alpha * prices[i] + (1 - alpha) * ema[i - 1]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
 
100
  return ema
101
 
 
 
 
 
 
 
 
 
 
 
102
 
103
+ # ===============================================================
104
+ # Helper Function – Dynamic Scaling
105
+ # ===============================================================
106
+ def get_dynamic_minmax():
107
+ today = datetime.now().date()
108
+ start = today - timedelta(days=BASE_WINDOW)
109
+ df = load_yf_data(PAIR, start, today + timedelta(days=1))
110
+ close_min = df["close"].min()
111
+ close_max = df["close"].max()
112
+ logger.info(f"Dynamic Min/Max Close: {close_min:.5f} / {close_max:.5f}")
113
+ return float(close_min), float(close_max)
114
 
 
 
 
 
115
 
116
  def normalize_close(value, close_min, close_max):
117
  if close_max == close_min:
118
  return 0.5
119
+ return (value - close_min) / (close_max - close_min)
120
 
 
 
 
 
 
 
 
121
 
122
+ # ===============================================================
123
+ # Helper Function – Trend Analysis
124
+ # ===============================================================
125
+ def analyze_trend(latest_row):
126
+ ema20 = latest_row["EMA20"]
127
+ ema50 = latest_row["EMA50"]
128
+ close = latest_row["close"]
129
 
130
  if ema20 > ema50:
131
  trend = "bullish"
 
134
  else:
135
  trend = "neutral"
136
 
137
+ diff = abs(ema20 - ema50) / ema50 * 100 if ema50 != 0 else 0
138
+ if diff > 0.3:
 
 
 
 
139
  strength = "strong"
140
+ elif diff > 0.1:
141
  strength = "moderate"
142
  else:
143
  strength = "weak"
 
153
  "trend": trend,
154
  "strength": strength,
155
  "price_position": price_position,
156
+ "ema_gap_percent": round(diff, 3)
157
  }
158
 
159
+
160
+ # ===============================================================
161
+ # Endpoint: /analyze
162
+ # ===============================================================
163
  @app.post("/analyze")
164
  def analyze_ema_endpoint(input_data: DateRange):
 
 
 
 
 
165
  try:
166
+ start_date = pd.to_datetime(input_data.start_date)
167
+ end_date = pd.to_datetime(input_data.end_date)
168
+ extended_start = start_date - timedelta(days=60)
 
 
 
169
 
170
+ df = load_yf_data(PAIR, extended_start, end_date + timedelta(days=1))
171
+ if len(df) < 50:
172
+ return {"status": "error", "message": "Data terlalu sedikit (butuh minimal 50 hari)."}
173
 
174
+ df["EMA20"] = ema_manual(df["close"].values.tolist(), 20)
175
+ df["EMA50"] = ema_manual(df["close"].values.tolist(), 50)
176
+ df = df.dropna().reset_index(drop=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
 
 
 
178
  close_min, close_max = get_dynamic_minmax()
179
+ df["norm_close"] = df["close"].apply(lambda x: normalize_close(x, close_min, close_max))
180
+
181
+ chart_data = {
182
+ "dates": [str(d) for d in df["date"].tolist()],
183
+ "close": df["close"].round(6).tolist(),
184
+ "EMA20": df["EMA20"].round(6).tolist(),
185
+ "EMA50": df["EMA50"].round(6).tolist(),
186
+ "norm_close": df["norm_close"].round(6).tolist(),
187
+ "min_close": close_min,
188
+ "max_close": close_max
189
+ }
190
+
191
+ return {
192
+ "status": "ok",
193
+ "pair": PAIR,
194
+ "start_date": str(start_date.date()),
195
+ "end_date": str(end_date.date()),
196
+ "data_points": len(df),
197
+ "chart_data": chart_data
198
+ }
199
+
200
  except Exception as e:
201
+ logger.error(f"Error di /analyze: {e}", exc_info=True)
202
+ return {"status": "error", "message": str(e)}
 
 
 
 
 
 
 
 
 
 
 
 
203
 
 
 
 
 
 
 
 
 
 
204
 
205
+ # ===============================================================
206
+ # Endpoint: /summary
207
+ # ===============================================================
208
  @app.post("/summary")
209
  def ema_summary_endpoint(input_data: DateRange):
 
 
 
210
  try:
211
+ start_date = pd.to_datetime(input_data.start_date)
212
+ end_date = pd.to_datetime(input_data.end_date)
213
+ extended_start = start_date - timedelta(days=60)
214
+
215
+ df = load_yf_data(PAIR, extended_start, end_date + timedelta(days=1))
216
+ if len(df) < 50:
217
+ return {"status": "error", "message": "Data terlalu sedikit (butuh minimal 50 hari)."}
218
+
219
+ df["EMA20"] = ema_manual(df["close"].values.tolist(), 20)
220
+ df["EMA50"] = ema_manual(df["close"].values.tolist(), 50)
221
+ df = df.dropna().reset_index(drop=True)
222
+
223
+ latest = df.iloc[-1]
224
+ analysis = analyze_trend(latest)
225
+
226
+ return {
227
+ "status": "ok",
228
+ "pair": PAIR,
229
+ "as_of_date": str(latest["date"]),
230
+ "close": round(float(latest["close"]), 6),
231
+ "EMA20": round(float(latest["EMA20"]), 6),
232
+ "EMA50": round(float(latest["EMA50"]), 6),
233
+ "trend_analysis": analysis
234
+ }
235
 
 
 
236
  except Exception as e:
237
+ logger.error(f"Error di /summary: {e}", exc_info=True)
238
+ return {"status": "error", "message": str(e)}
 
 
 
 
 
 
239
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
 
241
+ # ===============================================================
242
+ # Root Test
243
+ # ===============================================================
244
  @app.get("/")
245
  def root():
246
  return {"message": "Model B API (EMA + Trend Summary) aktif πŸš€"}