nas08-scripts/Stocks/WebService/app.py

171 lines
5.3 KiB
Python
Raw Normal View History

2026-07-27 20:01:02 -04:00
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)