marketapi2 / app.py
eshan6704's picture
Rename app-1.py to app.py
9ad85e3 verified
Raw History Blame Contribute Delete
34.8 kB
"""
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/<date>",
"bulkdeals": "/api/bulkdeals",
"blockdeals": "/api/blockdeals",
"equity_volatility": "/api/equity_volatility/<date>",
"fo_volatility": "/api/fo_volatility/<date>",
"fo_bhavcopy": "/api/fo_bhavcopy/<date>",
"fo_secban": "/api/fo_secban",
"indices_bhavcopy": "/api/indices_bhavcopy/<date>",
"pr_bhavcopy_bundle": "/api/pr_bhavcopy_bundle/<date>",
"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/<symbol>?start=DD-MM-YYYY&end=DD-MM-YYYY&series=EQ",
"index_chain": "/api/index_chain/<symbol>?expiry=DD-MM-YYYY",
"stock_chain": "/api/stock_chain/<symbol>?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/<date>",
"largedeals": "/api/largedeals?mode=bulk_deals|block_deals|short_deals",
"eq_symbols": "/api/eq_symbols",
"highlow": "/api/highlow/<date>",
"stock_highlow": "/api/stock_highlow/<date>",
"today_str": "/api/today_str?fmt=%d-%m-%Y",
"last_trading_day": "/api/last_trading_day",
},
"yahoo": {
"info": "/api/yf_info/<symbol>",
"history": "/api/yf_history/<symbol>?period=1y&interval=1d",
"intraday": "/api/yf_intraday/<symbol>?interval=5m&period=5d",
"daily_trend": "/api/yf_daily_trend/<symbol>?period=1mo",
"actions": "/api/yf_actions/<symbol> (dividends/splits/capital_gains are rows in this table)",
"income_stmt": "/api/yf_income_stmt/<symbol>?quarterly=false",
"balance_sheet": "/api/yf_balance_sheet/<symbol>?quarterly=false",
"cashflow": "/api/yf_cashflow/<symbol>?quarterly=false",
"all_financials": "/api/yf_all_financials/<symbol> (all 6 statements stacked, _source column)",
"earnings": "/api/yf_earnings/<symbol>?quarterly=false",
"estimates": "/api/yf_estimates/<symbol> (all 5 estimate tables stacked, _source column)",
"recommendations": "/api/yf_recommendations/<symbol>",
"analyst": "/api/yf_analyst/<symbol> (JSON: price_targets/summary/upgrades_downgrades as separate tables; CSV: all stacked with _source column)",
"holders": "/api/yf_holders/<symbol> (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/<group>",
"refresh_group": "POST /fastinfo/refresh/<group>",
"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/<date>", 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/<date>", methods=["GET"])
def api_equity_volatility(date):
return respond(md.equity_volatility, f"equity_volatility_{date}.csv", None, date)
@app.route("/api/fo_volatility/<date>", methods=["GET"])
def api_fo_volatility(date):
return respond(md.fo_volatility, f"fo_volatility_{date}.csv", None, date)
@app.route("/api/fo_bhavcopy/<date>", 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/<date>", 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/<date>", 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/<symbol>", 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/<symbol>", 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/<symbol>", 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/<date>", 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/<date_str>", methods=["GET"])
def api_highlow(date_str):
return respond(md.highlow, f"highlow_{date_str}.csv", None, date_str)
@app.route("/api/stock_highlow/<date_str>", 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/<symbol>", 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/<symbol>", 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/<symbol>", 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/<symbol>", 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/<symbol>", 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/<symbol>", 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/<symbol>", 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/<symbol>", 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/<symbol>", 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/<symbol>", 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/<symbol>", 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/<symbol>", 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/<symbol>", 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/<symbol>", 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/<group>", methods=["GET"])
def fastinfo_group_snapshot(group):
"""One group's cached data as a CSV table. See /fastinfo/refresh/<group> 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/<group>", 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)