SPB Git

spb/findata

Public
7.9 KB · 161 lines tsx
1"use client";2import { useEffect, useMemo, useState, type FormEvent } from "react";3import { Download, RefreshCw } from "lucide-react";4import { api, type Dataset, type ExportRow } from "@/lib/api";5import { useAsync, useInterval } from "@/lib/hooks";6import { useToast } from "@/lib/toast";7import { daysAgo, fmtBytes, fmtInt, fmtRelative, fmtTs, splitList, today } from "@/lib/format";8import { Banner, Card, ErrorState, Field, PageHeader, StatusBadge } from "@/components/ui";9import { DataTable } from "@/components/DataTable";10import { JsonView } from "@/components/JsonView";1112export default function ExportsPage() {13  const toast = useToast();14  const datasets = useAsync((s) => api.get<{ data: Dataset[] }>("/catalog/datasets", undefined, s), []);15  const list = useAsync((s) => api.get<{ data: ExportRow[] }>("/exports", undefined, s), []);16  const live = useMemo(() => (list.data?.data ?? []).some((e) => e.status === "queued" || e.status === "running"), [list.data]);17  useInterval(list.reload, live ? 3000 : null);1819  const [datasetId, setDatasetId] = useState("market_bars.stock.1m.raw");20  const [symbols, setSymbols] = useState("");21  const [start, setStart] = useState(daysAgo(365));22  const [end, setEnd] = useState(today());23  const [adjustment, setAdjustment] = useState<"raw" | "split" | "split_div">("raw");24  const [format, setFormat] = useState<"parquet" | "csv">("parquet");25  const [busy, setBusy] = useState(false);2627  // Prefill from the Bars explorer (range too large)28  useEffect(() => {29    const p = new URLSearchParams(window.location.search);30    if (p.get("symbols")) setSymbols(p.get("symbols")!.replace(/,/g, "\n"));31    if (p.get("start")) setStart(p.get("start")!);32    if (p.get("end")) setEnd(p.get("end")!);33    const a = p.get("adjustment");34    if (a === "raw" || a === "split" || a === "split_div") setAdjustment(a);35  }, []);3637  const syms = splitList(symbols);3839  async function submit(e: FormEvent) {40    e.preventDefault();41    setBusy(true);42    try {43      const out = await api.post<{ id: string; status: string; poll: string }>("/exports", { dataset_id: datasetId, symbols: syms, start, end, adjustment, format });44      toast.success("Export queued", out.id);45      list.reload();46    } catch (err) {47      toast.error(err);48    } finally {49      setBusy(false);50    }51  }5253  return (54    <>55      <PageHeader56        title="Exports"57        subtitle="Asynchronous Parquet/CSV extracts for ranges larger than the live API allows. Dataset version is pinned at submission."58        actions={59          <button className="btn btn-sm" onClick={list.reload}>60            <RefreshCw className="h-3.5 w-3.5" /> Refresh{live ? " (auto 3 s)" : ""}61          </button>62        }63      />64      <div className="grid gap-4 lg:grid-cols-[360px_minmax(0,1fr)]">65        <Card title="New export">66          <form onSubmit={submit} className="flex flex-col gap-3">67            <Field label="Dataset">68              <select className="input font-mono text-xs" value={datasetId} onChange={(e) => setDatasetId(e.target.value)}>69                {!(datasets.data?.data ?? []).some((d) => d.id === datasetId) && <option value={datasetId}>{datasetId}</option>}70                {(datasets.data?.data ?? []).filter((d) => d.status === "active").map((d) => (71                  <option key={d.id} value={d.id}>72                    {d.id}73                  </option>74                ))}75              </select>76            </Field>77            <Field label={`Symbols (${syms.length}/500)`} hint="comma, space or newline separated">78              <textarea className="input min-h-24 font-mono uppercase" value={symbols} onChange={(e) => setSymbols(e.target.value)} placeholder={"AAPL\nMSFT\nSPY"} required />79            </Field>80            <div className="grid grid-cols-2 gap-2">81              <Field label="Start">82                <input type="date" className="input" value={start} max={end} onChange={(e) => setStart(e.target.value)} required />83              </Field>84              <Field label="End">85                <input type="date" className="input" value={end} min={start} onChange={(e) => setEnd(e.target.value)} required />86              </Field>87              <Field label="Adjustment">88                <select className="input" value={adjustment} onChange={(e) => setAdjustment(e.target.value as typeof adjustment)}>89                  <option value="raw">raw</option>90                  <option value="split">split</option>91                  <option value="split_div">split + dividend</option>92                </select>93              </Field>94              <Field label="Format">95                <select className="input" value={format} onChange={(e) => setFormat(e.target.value as typeof format)}>96                  <option value="parquet">Parquet (zstd)</option>97                  <option value="csv">CSV</option>98                </select>99              </Field>100            </div>101            <button className="btn btn-primary" disabled={busy || syms.length === 0 || syms.length > 500}>102              {busy ? "Submitting…" : "Queue export"}103            </button>104            <p className="text-[11px] text-ink-3">Limits: 500 symbols, 12 years, 3 concurrent exports per principal. Files expire after ~72 h. Private use only — do not redistribute.</p>105          </form>106        </Card>107108        <Card title={`Exports (${list.data?.data.length ?? 0})`}>109          {list.error && <ErrorState error={list.error} retry={list.reload} />}110          <DataTable111            rows={list.data?.data}112            loading={list.loading}113            rowKey={(e) => e.id}114            mobile="cards"115            columns={[116              { key: "created_at", header: "Created", render: (e) => <span className="tnum text-xs" title={fmtTs(e.created_at)}>{fmtRelative(e.created_at)}</span> },117              { key: "status", header: "Status", render: (e) => <StatusBadge status={isExpired(e) ? "expired" : e.status} label={isExpired(e) ? "expired" : e.status} /> },118              {119                key: "request",120                header: "Request",121                render: (e) => (122                  <div className="text-xs">123                    <div className="font-mono">{e.request.dataset_id}{e.request.version !== undefined ? ` v${e.request.version}` : ""}</div>124                    <div className="text-ink-2">125                      {e.request.symbols.length} symbol{e.request.symbols.length === 1 ? "" : "s"} · {e.request.start} → {e.request.end} · {e.request.adjustment} · {e.format}126                    </div>127                    <div className="line-clamp-1 font-mono text-[11px] text-ink-3">{e.request.symbols.join(", ")}</div>128                  </div>129                ),130              },131              { key: "row_count", header: "Rows", num: true, hideSm: true, render: (e) => fmtInt(e.row_count) },132              { key: "bytes", header: "Size", num: true, render: (e) => fmtBytes(e.bytes) },133              { key: "expires_at", header: "Expires", hideSm: true, render: (e) => <span className="tnum text-xs">{e.expires_at ? fmtRelative(e.expires_at) : "—"}</span> },134              {135                key: "download",136                header: "",137                render: (e) =>138                  e.status === "ready" && !isExpired(e) ? (139                    <a className="btn btn-sm btn-primary" href={`/api/v1/exports/${e.id}/download`} target="_blank" rel="noopener">140                      <Download className="h-3.5 w-3.5" /> Download141                    </a>142                  ) : e.status === "failed" && e.error ? (143                    <JsonView value={e.error} collapsed />144                  ) : null,145              },146            ]}147            empty="No exports yet."148          />149        </Card>150      </div>151      <Banner tone="warn" className="mt-4">152        Exported files contain FirstRate Data bars licensed for <strong>private use by the subscriber only</strong>. Do not share, publish or re-host them.153      </Banner>154    </>155  );156}157158function isExpired(e: ExportRow): boolean {159  return e.status === "expired" || (!!e.expires_at && new Date(e.expires_at).getTime() < Date.now());160}161
Files · main
Menu

Theme