""" app.py — MarketAPI2 Flask Server (COMPLETE v2 + FastInfo) All market.py + yahoo_extended.py functions + FastInfo snapshot endpoints. Public Space: eshan6704/marketapi2 on HuggingFace CSV-only: every endpoint returns text/csv. There is no JSON output anywhere in this file (no jsonify, no Flask JSON provider) — status/metadata endpoints that used to be small JSON dicts (health, endpoints list, fastinfo groups/refresh, error responses) are now flattened into key,value CSV via dict_to_csv(), and tabular endpoints go through market.py's md.to_csv() as before. """ from flask import Flask, request, Response, render_template, redirect import market as md import market as yf_ext import logging import pandas as pd import math from datetime import datetime app = Flask(__name__, template_folder="templates") # FastInfo keeps its own hourly background refresh thread (the one # exception to "on-demand only" — every other NSE/Yahoo function in # market.py is strictly request-triggered). Live/on-demand refreshes via # /fastinfo/refresh* share the same 1-minute floor as the background loop. md.start_background() # ─────────────────────────── DICT-TO-CSV (status/meta endpoints) ────────── def _clean_csv_value(v): """NaN/Inf -> blank, lists/tuples -> '; '-joined string, everything else passed through as-is for csv.writer to stringify.""" if v is None: return "" if isinstance(v, float) and (math.isnan(v) or math.isinf(v)): return "" if isinstance(v, (list, tuple)): return "; ".join(str(_clean_csv_value(x)) for x in v) return v def _flatten_dict(d, parent_key=""): """Flatten a nested dict into dot-path -> value pairs, in insertion order. E.g. {"a": {"b": 1}} -> {"a.b": 1}. Lists are left as lists (joined into a single cell later by _clean_csv_value) rather than exploded into indexed keys — keeps small status dicts (module lists, endpoint groups) readable as one row instead of a.0, a.1, a.2...""" items = {} for k, v in d.items(): key = f"{parent_key}.{k}" if parent_key else str(k) if isinstance(v, dict): items.update(_flatten_dict(v, key)) else: items[key] = v return items def dict_to_csv(d: dict) -> str: """ Flatten a (possibly nested) status/metadata dict into two-column 'key,value' CSV — used for every endpoint that used to return a small JSON dict (health, endpoint listing, fastinfo refresh confirmations, error responses) rather than a market-data table. """ flat = _flatten_dict(d) df = pd.DataFrame( [{"key": k, "value": _clean_csv_value(v)} for k, v in flat.items()] ) return md.to_csv(df) def csv_status_response(data: dict, status_code: int = 200, filename: str = "status.csv") -> Response: """dict_to_csv, wrapped in a Response with the right status code — the CSV-only equivalent of the old safe_jsonify().""" csv_string = dict_to_csv(data) resp = Response( csv_string, mimetype="text/csv", headers={"Content-Disposition": f"attachment;filename={filename}"}, ) resp.status_code = status_code return resp def records_to_csv(rows: list, filename: str = "data.csv") -> Response: """List of flat dicts (e.g. one row per symbol/group) -> CSV Response. Used for the fastinfo endpoints, which are genuinely tabular (many symbols/groups sharing the same fields) rather than a single status dict, so they get a real table instead of a key,value dump.""" if not rows: return Response("No data available", status=204, mimetype="text/plain") df = pd.DataFrame(rows) return csv_response(md.to_csv(df), filename) # ─────────────────────────── LOGGING ────────────────────────────────────── logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s — %(message)s", ) log = logging.getLogger(__name__) # ─────────────────────────── HELPERS ────────────────────────────────────── def csv_response(csv_string: str, filename: str = "data.csv") -> Response: """Wrap CSV string in Flask Response with proper headers.""" if not csv_string: return Response("No data available", status=204, mimetype="text/plain") return Response( csv_string, mimetype="text/csv", headers={"Content-Disposition": f"attachment;filename={filename}"} ) def safe_csv(func, filename: str, *args, **kwargs): """Safely call a function and return CSV response.""" try: result = func(*args, **kwargs) if isinstance(result, dict) and all(isinstance(v, pd.DataFrame) for v in result.values()): for k, v in result.items(): if not v.empty: csv = md.to_csv(v) return csv_response(csv, f"{filename.rsplit('.', 1)[0]}_{k}.csv") return Response("No data available", status=204, mimetype="text/plain") csv = md.to_csv(result) return csv_response(csv, filename) except Exception as e: log.error("Error in %s: %s", func.__name__, e) return csv_status_response({"error": str(e)}, 500) def want_raw() -> bool: """ Whether the caller wants unformatted raw numbers instead of the default Lakh/Crore human-readable formatting. Opt-in via ?raw=true — every endpoint that supports it defaults to human_readable=True, since the whole point of a human-facing CSV is not showing "782132800000.0". """ return request.args.get("raw", "false").strip().lower() == "true" def respond(func, filename, symbol=None, *args, **kwargs): """ Call func and return a CSV response. `symbol` is accepted for call-site compatibility (every /api/* route still passes it) but no longer used — CSV-only now, no format negotiation. """ return safe_csv(func, filename, *args, **kwargs) def stack_for_csv(result: dict, skip=("symbol",)) -> pd.DataFrame: """ Combine a dict of tables (DataFrames and/or plain dicts) into one flat DataFrame for CSV export, tagged with a leading '_source' column so each original table stays identifiable after stacking. Used for endpoints that return several small related tables (holders, estimates, analyst snapshot, calendar+esg, financial statements) — CSV can't hold nested structure, so this keeps everything in one file instead of only the first non-empty table. """ frames = [] for key, val in result.items(): if key in skip: continue if isinstance(val, pd.DataFrame): df = val elif isinstance(val, dict): df = pd.DataFrame([val]) else: continue if df.empty: continue df = df.copy() df.insert(0, "_source", key) frames.append(df) if not frames: return pd.DataFrame() return pd.concat(frames, ignore_index=True, sort=False) # ═══════════════════════════════════════════════════════════════════════════ # HEALTH & INFO # ═══════════════════════════════════════════════════════════════════════════ @app.route("/", methods=["GET"]) def index(): """Serve the merged FastInfo + API Console page (single-page, tab-switched).""" return render_template("index.html") @app.route("/health", methods=["GET"]) def health(): """Health check endpoint.""" return csv_status_response({ "status": "ok", "space": "eshan6704/marketapi2", "modules": ["market.py"], "endpoints": { "data": "/api/* (CSV)", "ui": "/ (single page: Overview tab + Console tab)" }, "network": md.network_stats(), }, filename="health.csv") @app.route("/admin/cache/clear", methods=["POST"]) def clear_cache(): """Manually flush the in-memory TTL cache (e.g. after a known bad/stale fetch).""" md._cache.clear() return csv_status_response({"status": "cache cleared"}, filename="cache_clear.csv") @app.route("/api/endpoints", methods=["GET"]) def list_endpoints(): """List all available endpoints as a category,endpoint,pattern CSV table.""" endpoints = { "nse": { "bhavcopy": "/api/bhavcopy/", "bulkdeals": "/api/bulkdeals", "blockdeals": "/api/blockdeals", "equity_volatility": "/api/equity_volatility/", "fo_volatility": "/api/fo_volatility/", "fo_bhavcopy": "/api/fo_bhavcopy/", "fo_secban": "/api/fo_secban", "indices_bhavcopy": "/api/indices_bhavcopy/", "pr_bhavcopy_bundle": "/api/pr_bhavcopy_bundle/", "insider_trading": "/api/insider_trading?symbol=TCS&from_date=DD-MM-YYYY&to_date=DD-MM-YYYY", "sast_disclosures": "/api/sast_disclosures?symbol=TCS&from_date=DD-MM-YYYY&to_date=DD-MM-YYYY", "pledged_shares": "/api/pledged_shares?symbol=TCS&from_date=DD-MM-YYYY&to_date=DD-MM-YYYY", "nse_circulars": "/api/nse_circulars?from_date=DD-MM-YYYY&to_date=DD-MM-YYYY&dept=CD", "market_status": "/api/market_status", "preopen": "/api/preopen?key=NIFTY|BANKNIFTY|FO|SME|ALL", "most_active": "/api/most_active?type=securities|etf&sort=value|volume", "gainers_losers": "/api/gainers_losers?index=NIFTY&category=gainers|losers", "price_band_hitters": "/api/price_band_hitters?band=both&view=AllSec", "circuit_breakers": "/api/circuit_breakers", "sector_watch": "/api/sector_watch", "indices": "/api/indices?filter=true|false", "index_constituents": "/api/index_constituents?index=NIFTY%2050&noofrecords=0", "index_pe_pb": "/api/index_pe_pb?index_name=NIFTY%2050", "index_hist": "/api/index_hist?index_name=NIFTY%2050&start=DD-MM-YYYY&end=DD-MM-YYYY", "stock_hist": "/api/stock_hist/?start=DD-MM-YYYY&end=DD-MM-YYYY&series=EQ", "index_chain": "/api/index_chain/?expiry=DD-MM-YYYY", "stock_chain": "/api/stock_chain/?expiry=DD-MM-YYYY", "oi_spurts": "/api/oi_spurts?mode=oi_spurts|oi_decrease|price_volume_spurts", "fno_lot_sizes": "/api/fno_lot_sizes", "events": "/api/events", "nse_results": "/api/nse_results?index=equities|debt|sme&period=Quarterly|Annual|Half-Yearly|Others", "nse_holidays": "/api/nse_holidays?type=trading|clearing", "fiidii": "/api/fiidii", "delivery_heatmap": "/api/delivery_heatmap/", "largedeals": "/api/largedeals?mode=bulk_deals|block_deals|short_deals", "eq_symbols": "/api/eq_symbols", "highlow": "/api/highlow/", "stock_highlow": "/api/stock_highlow/", "today_str": "/api/today_str?fmt=%d-%m-%Y", "last_trading_day": "/api/last_trading_day", }, "yahoo": { "info": "/api/yf_info/", "history": "/api/yf_history/?period=1y&interval=1d", "intraday": "/api/yf_intraday/?interval=5m&period=5d", "daily_trend": "/api/yf_daily_trend/?period=1mo", "actions": "/api/yf_actions/ (dividends/splits/capital_gains are rows in this table)", "income_stmt": "/api/yf_income_stmt/?quarterly=false", "balance_sheet": "/api/yf_balance_sheet/?quarterly=false", "cashflow": "/api/yf_cashflow/?quarterly=false", "all_financials": "/api/yf_all_financials/ (all 6 statements stacked, _source column)", "earnings": "/api/yf_earnings/?quarterly=false", "estimates": "/api/yf_estimates/ (all 5 estimate tables stacked, _source column)", "recommendations": "/api/yf_recommendations/", "analyst": "/api/yf_analyst/ (JSON: price_targets/summary/upgrades_downgrades as separate tables; CSV: all stacked with _source column)", "holders": "/api/yf_holders/ (JSON: major/institutional/mutualfund/insider_* as separate tables; CSV: all stacked with _source column)", }, "fastinfo": { "dashboard": "/ (merged into the single page's Overview tab)", "snapshot": "/fastinfo/snapshot", "group_snapshot": "/fastinfo/snapshot/", "refresh_group": "POST /fastinfo/refresh/", "refresh_all": "POST /fastinfo/refresh-all", "groups": "/fastinfo/groups", } } rows = [ {"category": category, "endpoint": name, "pattern": pattern} for category, group in endpoints.items() for name, pattern in group.items() ] return records_to_csv(rows, filename="endpoints.csv") # ═══════════════════════════════════════════════════════════════════════════ # UNIFIED API ENDPOINTS (CSV only) # ═══════════════════════════════════════════════════════════════════════════ @app.route("/api/bhavcopy/", methods=["GET"]) def api_bhavcopy(date): return respond(md.bhavcopy, f"bhavcopy_{date}.csv", None, date) @app.route("/api/bulkdeals", methods=["GET"]) def api_bulkdeals(): return respond(md.bulkdeals, "bulkdeals.csv", None, not want_raw()) @app.route("/api/blockdeals", methods=["GET"]) def api_blockdeals(): return respond(md.blockdeals, "blockdeals.csv", None, not want_raw()) @app.route("/api/equity_volatility/", methods=["GET"]) def api_equity_volatility(date): return respond(md.equity_volatility, f"equity_volatility_{date}.csv", None, date) @app.route("/api/fo_volatility/", methods=["GET"]) def api_fo_volatility(date): return respond(md.fo_volatility, f"fo_volatility_{date}.csv", None, date) @app.route("/api/fo_bhavcopy/", methods=["GET"]) def api_fo_bhavcopy(date): return respond(md.fo_bhavcopy, f"fo_bhavcopy_{date}.csv", None, date) @app.route("/api/fo_secban", methods=["GET"]) def api_fo_secban(): return respond(md.fo_secban, "fo_secban.csv", None) @app.route("/api/indices_bhavcopy/", methods=["GET"]) def api_indices_bhavcopy(date): return respond(md.indices_bhavcopy, f"indices_bhavcopy_{date}.csv", None, date) @app.route("/api/pr_bhavcopy_bundle/", methods=["GET"]) def api_pr_bhavcopy_bundle(date): return respond(md.pr_bhavcopy_bundle, f"pr_bhavcopy_bundle_{date}.csv", None, date) @app.route("/api/insider_trading", methods=["GET"]) def api_insider_trading(): symbol = request.args.get("symbol") from_date = request.args.get("from_date") to_date = request.args.get("to_date") return respond(md.insider_trading, "insider_trading.csv", symbol, symbol, from_date, to_date, not want_raw()) @app.route("/api/sast_disclosures", methods=["GET"]) def api_sast_disclosures(): symbol = request.args.get("symbol") from_date = request.args.get("from_date") to_date = request.args.get("to_date") return respond(md.sast_disclosures, "sast_disclosures.csv", symbol, symbol, from_date, to_date, not want_raw()) @app.route("/api/pledged_shares", methods=["GET"]) def api_pledged_shares(): symbol = request.args.get("symbol") from_date = request.args.get("from_date") to_date = request.args.get("to_date") return respond(md.pledged_shares, "pledged_shares.csv", symbol, symbol, from_date, to_date, not want_raw()) @app.route("/api/nse_circulars", methods=["GET"]) def api_nse_circulars(): from_date = request.args.get("from_date") to_date = request.args.get("to_date") dept = request.args.get("dept") return respond(md.nse_circulars, "nse_circulars.csv", None, from_date, to_date, dept) @app.route("/api/market_status", methods=["GET"]) def api_market_status(): return respond(md.market_status, "market_status.csv", None, not want_raw()) @app.route("/api/preopen", methods=["GET"]) def api_preopen(): key = request.args.get("key", "NIFTY") return respond(md.preopen, f"preopen_{key}.csv", None, key, not want_raw()) @app.route("/api/most_active", methods=["GET"]) def api_most_active(): type_ = request.args.get("type", "securities") sort = request.args.get("sort", "value") return respond(md.most_active, f"most_active_{type_}_{sort}.csv", None, type_, sort, not want_raw()) @app.route("/api/gainers_losers", methods=["GET"]) def api_gainers_losers(): index = request.args.get("index", "NIFTY") category = request.args.get("category", "gainers") return respond(md.gainers_losers, f"{category}_{index}.csv", None, index, category, not want_raw()) @app.route("/api/price_band_hitters", methods=["GET"]) def api_price_band_hitters(): band = request.args.get("band", "both") view = request.args.get("view", "AllSec") return respond(md.price_band_hitters, f"price_band_{band}_{view}.csv", None, band, view, not want_raw()) @app.route("/api/circuit_breakers", methods=["GET"]) def api_circuit_breakers(): return respond(md.circuit_breakers, "circuit_breakers.csv", None, not want_raw()) @app.route("/api/sector_watch", methods=["GET"]) def api_sector_watch(): return respond(md.sector_watch, "sector_watch.csv", None, not want_raw()) @app.route("/api/indices", methods=["GET"]) def api_indices(): filter_to_default = request.args.get("filter", "true").lower() == "true" return respond(md.indices, "indices.csv", None, filter_to_default) @app.route("/api/index_constituents", methods=["GET"]) def api_index_constituents(): """Live constituent stocks for an index. ?index=NIFTY%2050&noofrecords=0""" index_name = request.args.get("index", "NIFTY 50") noofrecords = int(request.args.get("noofrecords", "0")) return respond( md.index_constituents, f"constituents_{index_name.replace(' ', '_')}.csv", None, index_name, noofrecords ) @app.route("/api/index_pe_pb", methods=["GET"]) def api_index_pe_pb(): index_name = request.args.get("index_name", "NIFTY 50") return respond(md.index_pe_pb, f"index_pe_pb_{index_name.replace(' ', '_')}.csv", None, index_name) @app.route("/api/index_hist", methods=["GET"]) def api_index_hist(): index_name = request.args.get("index_name", "NIFTY 50") start = request.args.get("start") end = request.args.get("end") if not start or not end: return csv_status_response({"error": "Required: ?start=DD-MM-YYYY&end=DD-MM-YYYY"}, 400) return respond(md.index_hist, f"index_hist_{index_name.replace(' ', '_')}_{start}_{end}.csv", None, index_name, start, end) @app.route("/api/stock_hist/", methods=["GET"]) def api_stock_hist(symbol): start = request.args.get("start") end = request.args.get("end") series = request.args.get("series", "EQ") if not start or not end: return csv_status_response({"error": "Required: ?start=DD-MM-YYYY&end=DD-MM-YYYY"}, 400) try: return respond(md.stock_hist, f"hist_{symbol}_{start}_{end}.csv", symbol, start, end, symbol, series) except ValueError as e: return csv_status_response({"error": str(e)}, 400) @app.route("/api/index_chain/", methods=["GET"]) def api_index_chain(symbol): expiry = request.args.get("expiry") if not expiry: return csv_status_response({"error": "Required: ?expiry=DD-MM-YYYY"}, 400) return respond(md.index_chain, f"index_chain_{symbol}_{expiry}.csv", symbol, symbol, expiry) @app.route("/api/stock_chain/", methods=["GET"]) def api_stock_chain(symbol): expiry = request.args.get("expiry") if not expiry: return csv_status_response({"error": "Required: ?expiry=DD-MM-YYYY"}, 400) return respond(md.stock_chain, f"stock_chain_{symbol}_{expiry}.csv", symbol, symbol, expiry) @app.route("/api/oi_spurts", methods=["GET"]) def api_oi_spurts(): mode = request.args.get("mode", "oi_spurts") return respond(md.oi_spurts, f"oi_{mode}.csv", None, mode) @app.route("/api/fno_lot_sizes", methods=["GET"]) def api_fno_lot_sizes(): return respond(md.fno_lot_sizes, "fno_lot_sizes.csv", None) @app.route("/api/events", methods=["GET"]) def api_events(): return respond(md.events, "events.csv", None) @app.route("/api/nse_results", methods=["GET"]) def api_nse_results(): index = request.args.get("index", "equities") period = request.args.get("period", "Quarterly") try: return respond(md.nse_results, f"results_{index}_{period}.csv", None, index, period, not want_raw()) except ValueError as e: return csv_status_response({"error": str(e)}, 400) @app.route("/api/nse_holidays", methods=["GET"]) def api_nse_holidays(): type_ = request.args.get("type", "trading") return respond(md.nse_holidays, f"holidays_{type_}.csv", None, type_) @app.route("/api/fiidii", methods=["GET"]) def api_fiidii(): return respond(md.fiidii, "fiidii.csv", None, not want_raw()) @app.route("/api/delivery_heatmap/", methods=["GET"]) def api_delivery_heatmap(date): return respond(md.delivery_heatmap, f"delivery_heatmap_{date}.csv", None, date) @app.route("/api/largedeals", methods=["GET"]) def api_largedeals(): mode = request.args.get("mode", "bulk_deals") return respond(md.largedeals, f"{mode}.csv", None, mode, not want_raw()) @app.route("/api/eq_symbols", methods=["GET"]) def api_eq_symbols(): return respond(md.eq_symbols, "eq_symbols.csv", None) @app.route("/api/highlow/", methods=["GET"]) def api_highlow(date_str): return respond(md.highlow, f"highlow_{date_str}.csv", None, date_str) @app.route("/api/stock_highlow/", methods=["GET"]) def api_stock_highlow(date_str): return respond(md.stock_highlow, f"stock_highlow_{date_str}.csv", None, date_str) @app.route("/api/today_str", methods=["GET"]) def api_today_str(): fmt = request.args.get("fmt", "%d-%m-%Y") return respond(md.today_str, "today.csv", None, fmt) @app.route("/api/last_trading_day", methods=["GET"]) def api_last_trading_day(): return respond(md.last_trading_day, "last_trading_day.csv", None) @app.route("/api/yf_info/", methods=["GET"]) def api_yf_info(symbol): try: result = yf_ext.yf_info(symbol) return csv_response(md.to_csv(result), f"yf_info_{symbol}.csv") except Exception as e: return csv_status_response({"error": str(e)}, 500) @app.route("/api/yf_history/", methods=["GET"]) def api_yf_history(symbol): period = request.args.get("period", "1y") interval = request.args.get("interval", "1d") start = request.args.get("start") end = request.args.get("end") prepost = request.args.get("prepost", "false").lower() == "true" try: df = yf_ext.yf_history(symbol, period, interval, start, end, prepost) if df is None or df.empty: return csv_status_response({"symbol": symbol, "error": "No data"}, 404) return csv_response(md.to_csv(df), f"yf_history_{symbol}.csv") except Exception as e: return csv_status_response({"error": str(e)}, 400) @app.route("/api/yf_intraday/", methods=["GET"]) def api_yf_intraday(symbol): interval = request.args.get("interval", "5m") period = request.args.get("period", "5d") prepost = request.args.get("prepost", "false").lower() == "true" try: df = yf_ext.yf_intraday(symbol, interval, period, prepost) if df is None or df.empty: return csv_status_response({"symbol": symbol, "error": "No data"}, 404) return csv_response(md.to_csv(df), f"yf_intraday_{symbol}_{interval}.csv") except Exception as e: return csv_status_response({"error": str(e)}, 400) @app.route("/api/yf_daily_trend/", methods=["GET"]) def api_yf_daily_trend(symbol): period = request.args.get("period", "1mo") try: df = yf_ext.yf_daily_trend(symbol, period) if df is None or df.empty: return csv_status_response({"symbol": symbol, "error": "No data"}, 404) return csv_response(md.to_csv(df), f"yf_trend_{symbol}.csv") except Exception as e: return csv_status_response({"error": str(e)}, 400) @app.route("/api/yf_actions/", methods=["GET"]) def api_yf_actions(symbol): return respond(yf_ext.yf_actions, f"yf_actions_{symbol}.csv", symbol, symbol) @app.route("/api/yf_income_stmt/", methods=["GET"]) def api_yf_income_stmt(symbol): quarterly = request.args.get("quarterly", "false").lower() == "true" return respond(yf_ext.yf_income_stmt, f"yf_income_{symbol}.csv", symbol, symbol, quarterly, not want_raw()) @app.route("/api/yf_balance_sheet/", methods=["GET"]) def api_yf_balance_sheet(symbol): quarterly = request.args.get("quarterly", "false").lower() == "true" return respond(yf_ext.yf_balance_sheet, f"yf_balance_{symbol}.csv", symbol, symbol, quarterly, not want_raw()) @app.route("/api/yf_cashflow/", methods=["GET"]) def api_yf_cashflow(symbol): quarterly = request.args.get("quarterly", "false").lower() == "true" return respond(yf_ext.yf_cashflow, f"yf_cashflow_{symbol}.csv", symbol, symbol, quarterly, not want_raw()) @app.route("/api/yf_all_financials/", methods=["GET"]) def api_yf_all_financials(symbol): """Stacks all 6 statement tables into one CSV file (tagged by _source column).""" try: result = yf_ext.yf_all_financials(symbol, human_readable=not want_raw()) combined = stack_for_csv(result) if combined.empty: return Response("No data available", status=204, mimetype="text/plain") return csv_response(md.to_csv(combined), f"yf_financials_{symbol}.csv") except Exception as e: return csv_status_response({"error": str(e)}, 500) @app.route("/api/yf_earnings/", methods=["GET"]) def api_yf_earnings(symbol): quarterly = request.args.get("quarterly", "false").lower() == "true" return respond(yf_ext.yf_earnings, f"yf_earnings_{symbol}.csv", symbol, symbol, quarterly) @app.route("/api/yf_estimates/", methods=["GET"]) def api_yf_estimates(symbol): """Stacks all 5 estimate tables into one CSV file (tagged by _source column).""" try: result = yf_ext.yf_estimates(symbol) combined = stack_for_csv(result) if combined.empty: return Response("No data available", status=204, mimetype="text/plain") return csv_response(md.to_csv(combined), f"yf_estimates_{symbol}.csv") except Exception as e: return csv_status_response({"error": str(e)}, 500) @app.route("/api/yf_recommendations/", methods=["GET"]) def api_yf_recommendations(symbol): return respond(yf_ext.yf_recommendations, f"yf_recs_{symbol}.csv", symbol, symbol) @app.route("/api/yf_analyst/", methods=["GET"]) def api_yf_analyst(symbol): """Stacks price_targets + summary + upgrades_downgrades into one CSV file (tagged by _source column).""" try: result = yf_ext.yf_analyst(symbol) combined = stack_for_csv(result) if combined.empty: return Response("No data available", status=204, mimetype="text/plain") return csv_response(md.to_csv(combined), f"yf_analyst_{symbol}.csv") except Exception as e: return csv_status_response({"error": str(e)}, 500) @app.route("/api/yf_holders/", methods=["GET"]) def api_yf_holders(symbol): """Stacks all 6 holder tables into one CSV file (tagged by _source column).""" try: result = yf_ext.yf_holders(symbol) combined = stack_for_csv(result) if combined.empty: return Response("No data available", status=204, mimetype="text/plain") return csv_response(md.to_csv(combined), f"yf_holders_{symbol}.csv") except Exception as e: return csv_status_response({"error": str(e)}, 500) # ═══════════════════════════════════════════════════════════════════════════ # FASTINFO ENDPOINTS (Market Snapshot + On-Demand Refresh) # ═══════════════════════════════════════════════════════════════════════════ @app.route("/fastinfo", methods=["GET"]) def fastinfo_page(): """Legacy URL — FastInfo is now merged into the single page at '/'.""" return redirect("/") def _snapshot_group_rows(group_name: str, group_payload: dict) -> list: """Flatten one group's {"data": {symbol: {price, change_pct, ...}}, "failed": [...]} payload into a list of flat row dicts, one per symbol — this is what actually makes fastinfo's output a real table instead of a status dump.""" data = group_payload.get("data", {}) if isinstance(group_payload, dict) else {} failed = set(group_payload.get("failed", [])) if isinstance(group_payload, dict) else set() rows = [] for sym, fields in data.items(): row = {"group": group_name, "symbol": sym} if isinstance(fields, dict): row.update(fields) row["status"] = "failed" if sym in failed else "ok" rows.append(row) return rows @app.route("/fastinfo/snapshot", methods=["GET"]) def fastinfo_snapshot(): """Full snapshot as one CSV table (group, symbol, price, change_pct, currency, timestamp, status). Kept warm by FastInfo's hourly background thread — this just reads it. For a guaranteed-fresh ("live") read, use POST /fastinfo/refresh-all instead (min 1 minute between real fetches).""" snap = md.load_snapshot() if not snap: return csv_status_response({"error": "Snapshot not ready yet"}, 503) rows = [] for group_name, payload in snap.items(): if group_name == "_meta": continue rows.extend(_snapshot_group_rows(group_name, payload)) return records_to_csv(rows, filename="fastinfo_snapshot.csv") @app.route("/fastinfo/snapshot/", methods=["GET"]) def fastinfo_group_snapshot(group): """One group's cached data as a CSV table. See /fastinfo/refresh/ for a live read.""" data = md.get_group_data(group) if not data: return csv_status_response({"error": f"Group '{group}' not found"}, 404) rows = _snapshot_group_rows(group, data) return records_to_csv(rows, filename=f"fastinfo_{group}.csv") @app.route("/fastinfo/refresh/", methods=["POST"]) def fastinfo_refresh_group(group): """ INSTANT REFRESH for a specific group. Fetches live data, updates snapshot, returns fresh data as a CSV table. """ if group not in md.MARKET_SYMBOLS: return csv_status_response({ "error": f"Group '{group}' not found", "available": list(md.MARKET_SYMBOLS.keys()) }, 404) result = md.refresh_group(group) rows = _snapshot_group_rows(group, result) return records_to_csv(rows, filename=f"fastinfo_{group}_refreshed.csv") @app.route("/fastinfo/refresh-all", methods=["POST"]) def fastinfo_refresh_all(): """Manually trigger full refresh (all groups).""" md.refresh_all() return csv_status_response({ "status": "refreshed", "groups": list(md.MARKET_SYMBOLS.keys()), "timestamp": datetime.utcnow().isoformat(), }, filename="refresh_status.csv") @app.route("/fastinfo/groups", methods=["GET"]) def fastinfo_groups(): """List all groups with symbol counts and actual refresh cadence, as a CSV table. Every group shares the same real cadence: refreshed by the hourly background thread, or sooner on demand via /fastinfo/refresh*, subject to a shared 1-minute floor between real Yahoo fetches. FETCH_INTERVALS (per-group values) is legacy config not currently wired to a scheduler, so it's intentionally not reported here to avoid implying a cadence that doesn't exist. """ rows = [ { "group": group, "symbol_count": len(symbols), "background_refresh_sec": md.REFRESH_INTERVAL, "min_ondemand_refresh_sec": md.MIN_REFRESH_COOLDOWN, "symbols": symbols, } for group, symbols in md.MARKET_SYMBOLS.items() ] return records_to_csv(rows, filename="fastinfo_groups.csv") # ─────────────────────────── ERROR HANDLERS ────────────────────────────── from werkzeug.exceptions import HTTPException @app.errorhandler(HTTPException) def handle_http_exception(error): """Catch-all for every HTTP error Flask/Werkzeug can raise (400, 404, 405, 500, ...) — not just the two explicitly triggered by app logic — so no error path anywhere in this app can fall back to Flask's default JSON/HTML error page.""" if error.code and error.code >= 500: log.error("Internal error: %s", error) return csv_status_response( {"error": error.name, "message": error.description}, error.code or 500, filename="error.csv", ) @app.errorhandler(404) def not_found(error): return csv_status_response({"error": "Endpoint not found", "message": str(error)}, 404, filename="error.csv") @app.errorhandler(500) def internal_error(error): log.error("Internal error: %s", error) return csv_status_response({"error": "Internal server error"}, 500, filename="error.csv") # ─────────────────────────── STARTUP ────────────────────────────────────── if __name__ == "__main__": log.info("Starting MarketAPI2 (Complete v2 + FastInfo) on port 7860") app.run(debug=False, host="0.0.0.0", port=7860)