diff --git a/.env.example b/.env.example index 756a8a3..fcb2451 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,10 @@ # TradingView session ID for real-time data access # Get it from: DevTools > Application > Cookies > tradingview.com > sessionid TV_SESSION_ID= + +# API.CO.ID key for Indonesian holidays lookup +# Get from: https://api.co.id +API_CO_ID_KEY= + +# Path to SQLite database for holiday cache (default: holidays.db in server.py directory) +HOLIDAYS_DB_PATH= diff --git a/Dockerfile b/Dockerfile index 721f9af..cb87316 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,8 @@ RUN pip install --no-cache-dir "mcp>=1.0.0" "tradingview-screener>=3.0.0" "pytho COPY server.py . ENV TV_SESSION_ID="" +ENV API_CO_ID_KEY="" +ENV HOLIDAYS_DB_PATH="/app/holidays.db" EXPOSE 8000 diff --git a/SKILL.md b/SKILL.md index 8240be1..4e29f71 100644 --- a/SKILL.md +++ b/SKILL.md @@ -19,6 +19,9 @@ Indonesian stocks are accessed via `market_type="stocks"` and `market_country="i | `find_most_active(market_country="indonesia")` | Most active by volume | | `technical_scan(market_country="indonesia", ...)` | Screen using technical conditions | | `fundamental_scan(market_country="indonesia", ...)` | Screen using fundamental metrics | +| `check_holidays(mode="upcoming", limit=10)` | Check IDX holidays (SQLite-cached, `refresh=True` to re-fetch) | +| `check_holidays(mode="check", date="2026-05-14")` | Check if a specific date is a holiday | +| `check_holidays(mode="range", start_date="...", end_date="...")` | List holidays in a date range | --- @@ -459,7 +462,104 @@ fundamental_scan({ --- -## 6. Workflow: How to Run Full Analysis +## 6. Pre-Analysis Checklist + +Before running any analysis, always perform these 3 preliminary checks: + +### 6.1 Check Today's Date & Market Status + +IDX (Bursa Efek Indonesia) trading schedule: +- **Session 1:** 09:00 – 12:00 WIB +- **Session 2:** 13:30 – 15:50 WIB (pre-closing: 15:50-16:00) +- **Trading days:** Monday – Friday +- **Weekend:** Closed Saturday & Sunday + +Use `bash` with `date` to check today's date and day-of-week. Determine if market is open/closed. + +### 6.2 Check Indonesian Holidays (MCP Tool — SQLite Cached) + +IDX is also closed on national holidays. Holidays can cause multi-day breaks (e.g., 4 days off for long weekends), which significantly impacts BSJP and short-term strategies. + +**Use the `check_holidays` MCP tool instead of the shell script** — the data is stored in `holidays.db` (SQLite) so subsequent calls are instant: + +``` +# Upcoming holidays (default, next 10): +check_holidays(mode="upcoming", limit=10) + +# All holidays for a given year: +check_holidays(mode="year", year=2026) + +# Check if a specific date is a holiday: +check_holidays(mode="check", date="2026-05-14") + +# List holidays in a date range: +check_holidays(mode="range", start_date="2026-05-01", end_date="2026-05-31") + +# Force re-fetch from API (bypass cache): +check_holidays(mode="upcoming", refresh=True) +``` + +The first call fetches from the API and caches. Subsequent calls read directly from SQLite. Pass `refresh=True` to force a fresh fetch. + +Requires `API_CO_ID_KEY` in `.env`. + +**Holiday impact on analysis:** +- If today is the last trading day before a multi-day holiday, BSJP entries become **extended holds** (2-4 days instead of overnight). The gap at reopening can be larger. +- Historical candle data showing "flat" prices across dates may actually mean the market was closed — that the stock didn't move. +- Always cross-reference dates against the holiday calendar before drawing conclusions about price action. + +### 6.3 Include Market Calendar in Every Report + +``` +Analysis Date: {YYYY-MM-DD HH:MM WIB} +Day: {Monday/Tuesday/...} +Market Status: [OPEN / CLOSED] +Last Trading Day: {YYYY-MM-DD} +Next Trading Day: {YYYY-MM-DD} +Upcoming Holidays: {list any within next 14 days} +Holiday Note: {if next trading day is after a break, flag this} +``` + +### 6.4 News Search for Target Stocks + +Before analyzing any stock, search for recent news that may affect price: + +``` +websearch(query="{Stock Name} {Ticker} berita saham terbaru {YYYY}") +``` + +Key things to look for: +- Earnings releases / financial reports +- Insider buying/selling +- Sector-wide news (commodity prices, regulation changes) +- Corporate actions (stock split, rights issue, buyback) +- Analyst ratings changes + +Include relevant news in the report under a "Recent News & Catalysts" section. + +### 6.5 Determine Analysis Type + +Depending on what the user asks, pick one of these paths: + +| Analysis Type | Data Needed | +|--------------|-------------| +| **Full fundamental + technical** | All columns (Section 4 template) | +| **BSJP (Beli Sore Jual Pagi)** | Top losers today, RSI, volume, BB.lower, market cap | +| **Breakout scan** | RSI 50-70, SMA50/200, ADX >25, rel volume >1.5 | +| **Oversold bounce** | RSI <30, near SMA200/SMA50, volume confirmation | +| **Dividend hunting** | Dividend yield, payout ratio, P/E, market cap | + +--- + +## 7. Workflow: How to Run Full Analysis + +**Step 0** — Run Pre-Analysis Checklist (Section 6): +``` +date → check today's date & day +check_holidays(mode="upcoming") → check upcoming IDX holidays (SQLite-cached) +check_holidays(mode="check", date="YYYY-MM-DD") → verify if key dates are holidays +websearch → check news for target ticker +``` **Step 1** — Get company overview & fundamentals: ``` @@ -485,7 +585,7 @@ screen_market({ --- -## 7. Field Reference (for constructing queries) +## 8. Field Reference (for constructing queries) ### Indonesian Stock Ticker Format - `IDX:BBRI` — Bank Rakyat Indonesia diff --git a/docker-compose.yml b/docker-compose.yml index 4ed8529..8c5aa3c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,4 +6,11 @@ services: - "8000:8000" env_file: - .env + environment: + - HOLIDAYS_DB_PATH=/app/holidays.db + volumes: + - holidays-data:/app/holidays.db restart: unless-stopped + +volumes: + holidays-data: \ No newline at end of file diff --git a/server.py b/server.py index 39eeba9..1fd9b89 100644 --- a/server.py +++ b/server.py @@ -817,6 +817,249 @@ def clear_session() -> str: return "Session ID cleared" +# --------------------------------------------------------------------------- +# Holiday Checker (api.co.id) — SQLite backed +# --------------------------------------------------------------------------- + +import sqlite3 +from datetime import date as dt_date, datetime +from pathlib import Path + +_HOLIDAYS_API_KEY: str | None = None +_HOLIDAYS_DB: str | None = None + + +def _get_holidays_api_key() -> str | None: + global _HOLIDAYS_API_KEY + if _HOLIDAYS_API_KEY is None: + _HOLIDAYS_API_KEY = os.environ.get("API_CO_ID_KEY") or "" + return _HOLIDAYS_API_KEY or None + + +def _get_holidays_db_path() -> str: + global _HOLIDAYS_DB + if _HOLIDAYS_DB is None: + _HOLIDAYS_DB = os.environ.get("HOLIDAYS_DB_PATH") or str(Path(__file__).parent / "holidays.db") + return _HOLIDAYS_DB + + +def _init_holidays_db() -> sqlite3.Connection: + db_path = _get_holidays_db_path() + conn = sqlite3.connect(db_path) + conn.execute(""" + CREATE TABLE IF NOT EXISTS holidays ( + date TEXT PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL, + is_national INTEGER NOT NULL DEFAULT 1 + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_holidays_date ON holidays(date)") + conn.commit() + return conn + + +def _cache_holidays(items: list[dict]) -> None: + conn = _init_holidays_db() + for h in items: + conn.execute( + "INSERT OR REPLACE INTO holidays (date, name, type, is_national) VALUES (?, ?, ?, ?)", + (h["date"], h["name"], h.get("type", "Public Holiday"), 1 if h.get("is_national_holiday", True) else 0), + ) + conn.commit() + conn.close() + + +def _query_cached_holidays( + start_date: str = "", + end_date: str = "", + limit: int = 0, +) -> list[dict]: + conn = _init_holidays_db() + today = datetime.now().strftime("%Y-%m-%d") + if start_date and end_date: + rows = conn.execute( + "SELECT date, name, type, is_national FROM holidays WHERE date >= ? AND date <= ? ORDER BY date", + (start_date, end_date), + ).fetchall() + elif start_date: + rows = conn.execute( + "SELECT date, name, type, is_national FROM holidays WHERE date >= ? ORDER BY date", + (start_date,), + ).fetchall() + elif limit: + rows = conn.execute( + "SELECT date, name, type, is_national FROM holidays WHERE date >= ? ORDER BY date LIMIT ?", + (today, limit), + ).fetchall() + else: + rows = conn.execute( + "SELECT date, name, type, is_national FROM holidays WHERE date >= ? ORDER BY date", + (today,), + ).fetchall() + conn.close() + return [ + {"date": r[0], "name": r[1], "type": r[2], "is_national_holiday": bool(r[3])} + for r in rows + ] + + +def _fetch_and_cache_holidays(year: int) -> list[dict]: + """Fetch holidays for a year from API and cache them.""" + api_key = _get_holidays_api_key() + if not api_key: + return [] + + resp = requests.get( + "https://use.api.co.id/holidays/indonesia/", + headers={"x-api-co-id": api_key}, + params={"year": year}, + timeout=10, + ) + if resp.status_code != 200: + return [] + + data = resp.json() + if not data.get("is_success"): + return [] + + items = data.get("data", []) + if not isinstance(items, list): + items = [items] + + if items: + _cache_holidays(items) + return items + + +def _format_holidays(items: list[dict]) -> str: + if not items: + return "No holidays found." + + today = datetime.now().date() + lines: list[str] = [] + lines.append(f"{'Date':<15} {'Day':<10} {'Holiday':<45} {'Type':<20}") + lines.append("-" * 90) + for h in items: + d_str = h.get("date", "?") + name = h.get("name", "?") + typ = h.get("type", "?") + try: + dt = datetime.strptime(d_str, "%Y-%m-%d").date() + day_name = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"][dt.weekday()] + delta = (dt - today).days + if delta == 0: + suffix = " ⬥ TODAY" + elif delta == 1: + suffix = " ⬥ Tomorrow" + elif delta > 0: + suffix = f" ({delta}d away)" + elif delta < 0: + suffix = f" ({abs(delta)}d ago)" + else: + suffix = "" + except ValueError: + day_name = "?" + suffix = "" + lines.append(f"{d_str:<15} {day_name:<10} {name:<45} {typ:<20}{suffix}") + return "\n".join(lines) + + +@mcp.tool() +def check_holidays( + mode: str = "upcoming", + year: int | None = None, + date: str | None = None, + start_date: str | None = None, + end_date: str | None = None, + limit: int = 10, + refresh: bool = False, +) -> str: + """Check Indonesian public holidays (cached in SQLite). + + Modes: + upcoming (default): next N holidays + year: all holidays for a given year (pass `year`) + check: check if a specific date is a holiday (pass `date`, format YYYY-MM-DD) + range: holidays in a date range (pass `start_date` & `end_date`) + + Pass refresh=True to re-fetch from API instead of using cache. + Requires API_CO_ID_KEY env var to be set for API calls. + """ + match mode: + case "check": + if not date: + return "Error: `date` param required (YYYY-MM-DD)" + # Try cache first + cached = _query_cached_holidays(start_date=date, end_date=date) + if cached and not refresh: + return _format_holidays(cached) + # Fall back to API + api_key = _get_holidays_api_key() + if not api_key: + return "⚠ No cached data and API_CO_ID_KEY not set." + resp = requests.get( + "https://use.api.co.id/holidays/indonesia/check/date", + headers={"x-api-co-id": api_key}, + params={"date": date}, + timeout=10, + ) + if resp.status_code != 200: + return f"API error: HTTP {resp.status_code}" + data = resp.json() + if not data.get("is_success"): + return f"API error: {data.get('message', 'unknown')}" + d = data.get("data", {}) + if not d or not d.get("is_holiday"): + return f"{date} is NOT a holiday." + items = [d.get("holiday", {})] + return _format_holidays(items) + + case "range": + if not start_date or not end_date: + return "Error: `start_date` and `end_date` required" + cached = _query_cached_holidays(start_date=start_date, end_date=end_date) + if cached and not refresh: + return _format_holidays(cached) + api_key = _get_holidays_api_key() + if not api_key: + return "⚠ No cached data and API_CO_ID_KEY not set." + resp = requests.get( + "https://use.api.co.id/holidays/indonesia/check/range", + headers={"x-api-co-id": api_key}, + params={"start_date": start_date, "end_date": end_date}, + timeout=10, + ) + if resp.status_code != 200: + return f"API error: HTTP {resp.status_code}" + data = resp.json() + if not data.get("is_success"): + return f"API error: {data.get('message', 'unknown')}" + items = data.get("data", []) + if not isinstance(items, list): + items = [items] + return _format_holidays(items) + + case "year": + y = year or datetime.now().year + cached = _query_cached_holidays(start_date=f"{y}-01-01", end_date=f"{y}-12-31") + if cached and not refresh: + return _format_holidays(cached) + items = _fetch_and_cache_holidays(y) + return _format_holidays(items) + + case _: # upcoming + cached = _query_cached_holidays(limit=limit) + if cached and not refresh: + return _format_holidays(cached) + # Fetch current & next year to build cache, then return from cache + current_year = datetime.now().year + _fetch_and_cache_holidays(current_year) + _fetch_and_cache_holidays(current_year + 1) + cached = _query_cached_holidays(limit=limit) + return _format_holidays(cached) if cached else "No upcoming holidays found." + + if __name__ == "__main__": parser = argparse.ArgumentParser(description="TradingView Screener MCP Server") parser.add_argument(