import os import time import sqlite3 import threading from datetime import datetime, timedelta import requests from flask import Flask, request, jsonify app = Flask(__name__) # ---- Config ---- FINNHUB_API_KEY = os.environ.get("FINNHUB_API_KEY", "") SHARED_SECRET = os.environ.get("PROXY_SECRET", "") DB_PATH = os.environ.get("DB_PATH", "/data/cache.db") FINNHUB_MAX_PER_MIN = 40 # stay safely under Finnhub's 60/min cap _db_lock = threading.Lock() def get_db(): conn = sqlite3.connect(DB_PATH) conn.execute(""" CREATE TABLE IF NOT EXISTS historical_cache ( ticker TEXT NOT NULL, requested_date TEXT NOT NULL, resolved_date TEXT NOT NULL, price REAL NOT NULL, PRIMARY KEY (ticker, requested_date) ) """) return conn _current_cache = {} _current_cache_lock = threading.Lock() CURRENT_TTL_SECONDS = 60 _finnhub_calls = [] _finnhub_lock = threading.Lock() def finnhub_throttle(): with _finnhub_lock: now = time.time() while _finnhub_calls and _finnhub_calls[0] < now - 60: _finnhub_calls.pop(0) if len(_finnhub_calls) >= FINNHUB_MAX_PER_MIN: sleep_for = 60 - (now - _finnhub_calls[0]) + 0.1 time.sleep(max(sleep_for, 0)) _finnhub_calls.append(time.time()) def check_auth(): key = request.args.get("key", "") return bool(SHARED_SECRET) and key == SHARED_SECRET @app.route("/current") def current_price(): if not check_auth(): return jsonify({"error": "unauthorized"}), 401 ticker = request.args.get("ticker", "").upper().strip() if not ticker: return jsonify({"error": "missing ticker"}), 400 now = time.time() with _current_cache_lock: cached = _current_cache.get(ticker) if cached and (now - cached[1]) < CURRENT_TTL_SECONDS: return jsonify({"ticker": ticker, "price": cached[0], "quote_time": cached[2], "cached": True}) finnhub_throttle() r = requests.get( "https://finnhub.io/api/v1/quote", params={"symbol": ticker, "token": FINNHUB_API_KEY}, timeout=10, ) data = r.json() if not data or data.get("c") in (None, 0): return jsonify({"ticker": ticker, "error": "no data"}), 502 price = data["c"] quote_time = None if data.get("t"): quote_time = datetime.utcfromtimestamp(data["t"]).isoformat() + "Z" with _current_cache_lock: _current_cache[ticker] = (price, now, quote_time) return jsonify({"ticker": ticker, "price": price, "quote_time": quote_time, "cached": False}) @app.route("/historical") def historical_price(): if not check_auth(): return jsonify({"error": "unauthorized"}), 401 ticker = request.args.get("ticker", "").upper().strip() date_str = request.args.get("date", "").strip() if not ticker or not date_str: return jsonify({"error": "missing ticker or date"}), 400 try: target_date = datetime.strptime(date_str, "%Y-%m-%d") except ValueError: return jsonify({"error": "date must be YYYY-MM-DD"}), 400 with _db_lock: conn = get_db() row = conn.execute( "SELECT resolved_date, price FROM historical_cache WHERE ticker=? AND requested_date=?", (ticker, date_str), ).fetchone() conn.close() if row: return jsonify({"ticker": ticker, "requested_date": date_str, "resolved_date": row[0], "price": row[1], "cached": True}) period2 = int((target_date + timedelta(days=1)).timestamp()) period1 = int((target_date - timedelta(days=10)).timestamp()) url = f"https://query1.finance.yahoo.com/v8/finance/chart/{ticker}" headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"} r = requests.get(url, params={"period1": period1, "period2": period2, "interval": "1d"}, headers=headers, timeout=10) if r.status_code != 200: return jsonify({"ticker": ticker, "error": f"yahoo http {r.status_code}"}), 502 data = r.json() try: result = data["chart"]["result"][0] timestamps = result["timestamp"] closes = result["indicators"]["quote"][0]["close"] except (KeyError, IndexError, TypeError): return jsonify({"ticker": ticker, "error": "no historical data"}), 502 best = None for ts, close in zip(timestamps, closes): if close is None: continue day = datetime.utcfromtimestamp(ts) if day.date() <= target_date.date(): if best is None or day > best[0]: best = (day, close) if best is None: return jsonify({"ticker": ticker, "error": "no trading day found in window"}), 502 resolved_date = best[0].strftime("%Y-%m-%d") price = round(best[1], 4) with _db_lock: conn = get_db() conn.execute( "INSERT OR REPLACE INTO historical_cache (ticker, requested_date, resolved_date, price) VALUES (?, ?, ?, ?)", (ticker, date_str, resolved_date, price), ) conn.commit() conn.close() return jsonify({"ticker": ticker, "requested_date": date_str, "resolved_date": resolved_date, "price": price, "cached": False}) @app.route("/health") def health(): return jsonify({"status": "ok"}) if __name__ == "__main__": app.run(host="0.0.0.0", port=5005)