diff --git a/LOCAL.md b/LOCAL.md index ddeea7a..7ffabf2 100644 --- a/LOCAL.md +++ b/LOCAL.md @@ -6,13 +6,15 @@ Paper desk. Live Binance last for crypto fills. NSE/Yahoo when the cash session - **Node.js 22 LTS** — [https://nodejs.org](https://nodejs.org) (include npm) - Windows, macOS, or Linux -- Unzip this folder, e.g. `D:\work Dir\MeridianFinal` +- This operator’s working copy: `D:\work Dir\MeridianFinal-local\MeridianFinal` No Postgres required. The app uses embedded PGLite unless you set `DATABASE_URL`. ## Quick start (Windows) -1. Unzip to `D:\work Dir\MeridianFinal` +Working copy: **`D:\work Dir\MeridianFinal-local\MeridianFinal`** + +1. Open that folder 2. Double-click **`run.bat`** - First run: `npm install` (a few minutes) - Copies `.env.example` → `.env` if missing diff --git a/README.md b/README.md index d997f9d..b3aacc2 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,27 @@ # Meridian Final -Personal multi-asset desk (V1–V4). Paper first. Live Kite stays off. +**Canonical product.** V2 Greeks + V3 auto desk + V4 meta-label, in one paper desk. -- **Auto paper** — ₹10L book, farm sleeve (labels + costs) and PnL sleeve (Kelly, armed after meta promote). Binance overnight; NSE cash/F&O only in the cash session. 10× is a return target, not a promise. +Paper first. Live Kite stays off. Mock book ₹10,00,000. + +Older trees are **frozen**. Do not patch V3/V4. Do not use the MeridianV4 repo. + +- **Signals / Paper / Paper auto-send** — Signals proposes (Would BUY). Paper waits for Approve / Skip / Size. Paper auto-send fills on its own. Live is locked. - **Tape** — NSE, FX, COMEX/MCX, every Binance USDT pair -- **Book** — CSV → Buy / Hold / Sell -- **Greeks** — gamma scalping reviews -- **Research** — natural-language names -- **Advice** — spot / futures / options cards +- **Book** — CSV cash ledger, separate from the paper clip book. META is n/a until the paper model promotes. +- **Greeks** — what-if calculator (V2), not the live paper book +- **Research** — natural-language names, or empty if the query does not match +- **Advice** — spot / futures / options cards that respect session and promotion -**Run on your PC:** see [LOCAL.md](LOCAL.md) (`run.bat` on Windows, `run.sh` on macOS/Linux). +**Run on your PC:** see [LOCAL.md](LOCAL.md) (`run.bat` on Windows, `run.sh` on macOS/Linux). localhost and 127.0.0.1 both sign in. Test login: `WQ3137` / `Test@password` -Python engines: `meridian_final/`. Spec: [docs/BUILD_PLAN.md](docs/BUILD_PLAN.md) +| Doc | | +|-----|--| +| Lineage (V1→Final) | [docs/LINEAGE.md](docs/LINEAGE.md) | +| Build spec | [docs/BUILD_PLAN.md](docs/BUILD_PLAN.md) | +| UX contract | [docs/UX_BACKLOG.md](docs/UX_BACKLOG.md) | +| Python math ports | `meridian_final/` | Kite keys never belong in git. This is personal software. You can lose the whole book. Nothing here is an order. diff --git a/START_HERE.txt b/START_HERE.txt index 7e6c967..2210ce9 100644 --- a/START_HERE.txt +++ b/START_HERE.txt @@ -3,7 +3,7 @@ MERIDIAN FINAL — run on your PC Windows: 1. Install Node.js 22 LTS from https://nodejs.org (tick "Add to PATH") - 2. Unzip this folder to D:\work Dir\MeridianFinal + 2. Working copy: D:\work Dir\MeridianFinal-local\MeridianFinal 3. Double-click run.bat 4. Open http://localhost:3000 5. Login WQ3137 / Test@password diff --git a/docs/BUILD_PLAN.md b/docs/BUILD_PLAN.md index 1e7091f..11c561e 100644 --- a/docs/BUILD_PLAN.md +++ b/docs/BUILD_PLAN.md @@ -1,9 +1,10 @@ # Meridian Final — Build Master Spec **Last updated:** 23 Aug 2026 -**Repos:** [sanjaymaverick-cmd/MeridianFinal](https://github.com/sanjaymaverick-cmd/MeridianFinal) -**Lineage:** V1 advisor → V2 Greeks → V3 auto desk → V4 OpenAlgo / meta-label -**Isolation:** V3 and V4 stay frozen. Final copies math, never patches those trees. +**Canonical repo:** [sanjaymaverick-cmd/MeridianFinal](https://github.com/sanjaymaverick-cmd/MeridianFinal) +**Canonical product:** Meridian Final = V2 Greeks + V3 auto desk + V4 meta-label, one paper desk +**Lineage:** [LINEAGE.md](LINEAGE.md) — V1 advisor → V2 Greeks → V3 auto desk → V4 OpenAlgo / meta-label → **this repo** +**Isolation:** V3 and V4 stay frozen. Final copies math, never patches those trees. Do not use the MeridianV4 GitHub repo as a working tree. --- diff --git a/docs/LINEAGE.md b/docs/LINEAGE.md new file mode 100644 index 0000000..5882bf6 --- /dev/null +++ b/docs/LINEAGE.md @@ -0,0 +1,51 @@ +# Meridian Final — lineage + +**Canonical product:** Meridian Final +**Canonical repo:** [sanjaymaverick-cmd/MeridianFinal](https://github.com/sanjaymaverick-cmd/MeridianFinal) +**As of:** 23 Aug 2026 + +**Local checkout (operator PC):** `D:\work Dir\MeridianFinal-local\MeridianFinal` + +V1–V4 are frozen history. New work ships **only** here. Do not patch the V3 or V4 trees. Do not use [MeridianV4](https://github.com/sanjaymaverick-cmd/MeridianV4) as a working repo. + +``` +V1 advisor → V2 Greeks → V3 auto desk → V4 OpenAlgo / meta-label + ↓ + Meridian Final (this repo) + TypeScript desk + meridian_final/ Python ports +``` + +## What each version contributed + +| Version | What it was | What Final kept | Isolation | +|---------|-------------|-----------------|-----------| +| **V1** | Five-factor advisor. Book Buy / Hold / Sell. | Scoring on the imported cash book. | Math copied into `meridian_final/scoring.py` + Book UI. | +| **V2** | Greeks book, gamma scalping. | `/greeks` what-if calculator. Gamma-scalp reviews. Daily PnL = theta. Harvest = ½ Γ (ΔS)². | Copied into `meridian_final/greeks.py`, `gamma_scalp.py`. Not the live paper book. | +| **V3** | Auto paper desk. Short holds, EOD flatten. Honest 199-row meta set (21.6% win, ₹50k). | Farm sleeve, fill tape, gamma rehedge reviews. | V3 files (`build_meta_labels.py`, `meridian_v3_*`) are **read-only**. Never edit. | +| **V4** | OpenAlgo strategy host. mlfinlab-style meta. Longer-hold PnL sleeve. Promotion gates. | Decision-engine math. Farm 90s labels vs PnL (hard stop / 2.2R / trail). Promote only on paper fills. | V4 tree frozen. Ports in `meridian_final/decision.py`. OpenAlgo is optional, not the running desk. | + +## What Final is (and is not) + +**Is:** one personal paper desk. Mock ₹10,00,000. Signals / Paper / Paper auto-send. Live locked. Kite disarmed. Research that answers the query or returns empty. Promotion in English. + +**Is not:** a live broker. An OpenAlgo plugin. A continuation of the MeridianV4 GitHub repo. An order. + +## Promotion (from V4, tightened) + +PnL sleeve stays flat until **all** of: n ≥ 2000, test AUC ≥ 0.55, hit > 52%. Quality holds (≥300s) counted separately from 90s farm time-stops. Synth-only AUC does not promote. Book META is `n/a` until then — never fake 0%. + +## Execution path + +``` +Research (NL + universe + Grok when signed in) + ↓ +Scoring (V1) + Meta (V4 logistic, if promoted) + ↓ +Decision engine (gates, size, manage) + ↓ +Greeks / gamma reviews (V2) + ↓ +OMS: paper desk now → Kite / OpenAlgo later, only if LIVE_OK + static IP +``` + +Spec: [BUILD_PLAN.md](BUILD_PLAN.md). UX contract: [UX_BACKLOG.md](UX_BACKLOG.md). Local run: [../LOCAL.md](../LOCAL.md). diff --git a/docs/UX_BACKLOG.md b/docs/UX_BACKLOG.md new file mode 100644 index 0000000..54bdf35 --- /dev/null +++ b/docs/UX_BACKLOG.md @@ -0,0 +1,27 @@ +# Meridian Final — UX backlog (paper desk) + +Shipped on this branch. Constraint: mock capital. Analyzer / Paper only. Kite disarmed. Never “Arm”. + +| ID | Item | Where | +|---|---|---| +| UX-01 | Identity strip: MOCK · mode · ENGINE ON\|PAUSED · KITE DISARMED. Modes Signals / Paper / Paper auto-send / Live (locked). No `:live` fill suffix. | `desk-shell`, `ux-copy` | +| UX-02 | Pause ≠ Flatten. Pause keeps exits. Flatten confirms. Resume paper. Duplicate Kill gone. | `desk-shell`, `paper-engine` | +| UX-03 | Promotion verdict in English with n / AUC / hit pass-fail. Quality vs time-stop share. | `promotion-strip` | +| UX-04 | Book META is n/a until promote. Factor Buy decoupled. Two ledgers. | `portfolio.ts`, `portfolio.tsx` | +| UX-05 | localhost and 127.0.0.1 on :3000 and :8080. Guest cannot Pause/Reset/mode. Origin error in English. | `auth/server`, `login`, `desk-shell` | +| UX-06 | Paper mode pending Approve / Skip / Size (15s auto-skip). Flatten-one on clips. Auto-send is explicit. | `paper-engine`, `auto.tsx` | +| UX-07 | Signals last scan stays live with Would BUY / Would SELL. No new fills. | `paper-engine` tick | +| UX-08 | Research returns empty + reason on mismatch. Guest heuristic labelled. Run history. | `research-rank`, `research.tsx` | +| UX-09 | Last decision sentence. Heat vs cap. Farm hold countdown. PnL idle named. | `auto.tsx` | +| UX-10 | English fill/scan reasons. Quote source is not the word live. | `ux-copy` | +| UX-11 | Reset confirms. Not in the header thumb zone next to Pause. | `auto.tsx` | +| UX-12 | Download is the fit set; hold class labelled; count mismatch noted. | `auto.tsx`, `listSamples` | +| UX-13 | Weekend/cash-closed advice does not work NSE cash. Unpromoted meta is not a 0.55 gate to work. | `advice.ts` | +| UX-14 | Mode chips on mobile. Clip cards under md. No forced 720px table. | `desk-shell`, `auto.tsx` | +| UX-15 | Fresh desk starts Signals + paused. Start paper is labelled. | `emptyEngine`, header | +| UX-16 | Greeks titled what-if / calculator. Snapshot marks. | `greeks.tsx` | +| UX-17 | One timestamp column on Command fills. | `index.tsx` | +| UX-18 | Header is the mode source. Auto chips labelled mirrors. | `desk-shell`, `auto.tsx` | +| UX-19 | Book Block. Research Watch / Ignore. Greeks Queue paper hedge / Dismiss. | `desk.ts`, pages | + +Preserve: Kite off. Kill/Pause in the header. Pause does not flatten. Resume never sneaks to live. diff --git a/src/components/auto-engine.tsx b/src/components/auto-engine.tsx index a08976d..b18e516 100644 --- a/src/components/auto-engine.tsx +++ b/src/components/auto-engine.tsx @@ -1,6 +1,18 @@ import { useEffect } from "react"; import { useQuery } from "@tanstack/react-query"; -import { getPaperBook, resetPaperBook, setPaperFlags } from "@/lib/server/desk"; +import { + getPaperBook, + resetPaperBook, + setPaperFlags, + flattenPaperBook, + flattenPaperClip, + approvePaperPending, + skipPaperPending, + setPaperBlock, + setPaperWatch, + queuePaperHedge, + dismissPaperHedge, +} from "@/lib/server/desk"; import { useDesk } from "@/lib/desk-store"; export function AutoEngine() { @@ -20,7 +32,6 @@ export function AutoEngine() { } export async function setDeskMode(mode: "advisory" | "paper" | "auto") { - // Optimistic UI — engine used to force mode=auto on every poll; that is fixed. useDesk.getState().setMode(mode); try { const book = await setPaperFlags({ data: { mode } }); @@ -48,3 +59,75 @@ export async function resetDeskPaper() { console.error("[desk] reset failed", err); } } + +export async function flattenDeskPaper() { + try { + const book = await flattenPaperBook(); + useDesk.getState().hydratePaper(book); + } catch (err) { + console.error("[desk] flatten failed", err); + } +} + +export async function flattenDeskClip(symbol: string) { + try { + const book = await flattenPaperClip({ data: { symbol } }); + useDesk.getState().hydratePaper(book); + } catch (err) { + console.error("[desk] flatten clip failed", err); + } +} + +export async function approveDeskPending(id: string, sizePct?: number) { + try { + const book = await approvePaperPending({ data: { id, sizePct } }); + useDesk.getState().hydratePaper(book); + } catch (err) { + console.error("[desk] approve failed", err); + } +} + +export async function skipDeskPending(id: string) { + try { + const book = await skipPaperPending({ data: { id } }); + useDesk.getState().hydratePaper(book); + } catch (err) { + console.error("[desk] skip failed", err); + } +} + +export async function blockDeskSymbol(symbol: string, blocked: boolean) { + try { + const book = await setPaperBlock({ data: { symbol, blocked } }); + useDesk.getState().hydratePaper(book); + } catch (err) { + console.error("[desk] block failed", err); + } +} + +export async function watchDeskSymbol(symbol: string, watch: boolean) { + try { + const book = await setPaperWatch({ data: { symbol, watch } }); + useDesk.getState().hydratePaper(book); + } catch (err) { + console.error("[desk] watch failed", err); + } +} + +export async function queueDeskHedge(symbol: string, note: string, from = "greeks") { + try { + const book = await queuePaperHedge({ data: { symbol, note, from } }); + useDesk.getState().hydratePaper(book); + } catch (err) { + console.error("[desk] queue hedge failed", err); + } +} + +export async function dismissDeskHedge() { + try { + const book = await dismissPaperHedge(); + useDesk.getState().hydratePaper(book); + } catch (err) { + console.error("[desk] dismiss hedge failed", err); + } +} diff --git a/src/components/confirm.tsx b/src/components/confirm.tsx new file mode 100644 index 0000000..feea3ad --- /dev/null +++ b/src/components/confirm.tsx @@ -0,0 +1,38 @@ +import { Button } from "@/components/ui/button"; + +export function Confirm({ + title, + body, + action, + danger, + onCancel, + onOk, +}: { + title: string; + body: string; + action: string; + danger?: boolean; + onCancel: () => void; + onOk: () => void; +}) { + return ( +
+
+

{title}

+

{body}

+
+ + +
+
+
+ ); +} diff --git a/src/components/desk-shell.tsx b/src/components/desk-shell.tsx index a049a05..091ea47 100644 --- a/src/components/desk-shell.tsx +++ b/src/components/desk-shell.tsx @@ -1,14 +1,20 @@ -import type { ReactNode } from "react"; +import { useState, type ReactNode } from "react"; import { Link, useRouterState } from "@tanstack/react-router"; import { Activity, BookOpen, Compass, LayoutDashboard, LineChart, Sigma } from "lucide-react"; +import { toast } from "sonner"; import { UserButton } from "@/lib/auth/gates"; import { useCurrentUserState } from "@/lib/auth/use-current-user"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { useDesk } from "@/lib/desk-store"; +import { Confirm } from "@/components/confirm"; +import { useDesk, type DeskMode } from "@/lib/desk-store"; import { cn } from "@/lib/utils"; -import { AutoEngine, setDeskKilled, setDeskMode } from "@/components/auto-engine"; +import { inr } from "@/lib/utils"; +import { PAPER_BUDGET } from "@/lib/meridian/decision"; +import { AutoEngine, setDeskKilled, setDeskMode, flattenDeskPaper } from "@/components/auto-engine"; import { QuotesHydrator } from "@/components/quotes-hydrator"; +import { MODE_LABEL } from "@/lib/ux-copy"; +import { formatIstStamp } from "@/lib/utils"; const NAV = [ { to: "/", label: "Command", icon: LayoutDashboard }, @@ -19,23 +25,42 @@ const NAV = [ { to: "/research", label: "Research", icon: Compass }, ] as const; +const MODES: DeskMode[] = ["advisory", "paper", "auto"]; + export function DeskShell({ children }: { children: ReactNode }) { const pathname = useRouterState({ select: (s) => s.location.pathname }); const { user, isPending } = useCurrentUserState(); const mode = useDesk((s) => s.mode); const killed = useDesk((s) => s.killed); + const pausedAt = useDesk((s) => s.pausedAt); + const [askFlatten, setAskFlatten] = useState(false); + const guest = !isPending && !user; + const operator = Boolean(user); + const engineOn = !killed; + const modeLabel = MODE_LABEL[mode]; + + function guard(fn: () => void) { + if (!operator) { + toast.message("Guest view only. Sign in to Pause, Flatten, or change mode."); + return; + } + fn(); + } return (
-
+
Meridian Final - + {askFlatten && ( + setAskFlatten(false)} + onOk={() => { + setAskFlatten(false); + void flattenDeskPaper(); + }} + /> + )}
); } diff --git a/src/components/promotion-strip.tsx b/src/components/promotion-strip.tsx new file mode 100644 index 0000000..8da809a --- /dev/null +++ b/src/components/promotion-strip.tsx @@ -0,0 +1,60 @@ +import { Badge } from "@/components/ui/badge"; +import { PROMOTE_MIN_AUC, PROMOTE_MIN_HIT, PROMOTE_MIN_N } from "@/lib/meridian/kelly"; +import { promotionLines } from "@/lib/ux-copy"; + +export type MetaBits = { + n?: number; + auc?: number; + hitRate?: number; + promoted?: boolean; + source?: string; +}; + +export function PromotionStrip({ + meta, + closeStats, +}: { + meta?: MetaBits | null; + closeStats?: { n: number; timeStop: number; quality: number } | null; +}) { + const v = promotionLines(meta ?? undefined); + const n = meta?.n ?? 0; + const auc = meta?.auc ?? 0; + const hit = meta?.hitRate ?? 0; + const closed = closeStats?.n ?? 0; + const tsShare = closed > 0 ? (closeStats!.timeStop / closed) * 100 : 0; + const qShare = closed > 0 ? (closeStats!.quality / closed) * 100 : 0; + + return ( +
+
+
+

Promotion

+

{v.headline}

+

{v.body}

+
+ {v.promoted ? "PnL eligible" : "Farming"} +
+
    + + + +
+

+ {closed + ? `Closed clips: ${closed}. Quality holds ${qShare.toFixed(0)}% (≥300s / stop / take-profit). Time-stop ${tsShare.toFixed(0)}% (90s farm barrier). Source ${meta?.source ?? "synth"}.` + : "No closed clips yet. Quality vs time-stop share appears after the first exits."} +

+
+ ); +} + +function Gate({ ok, label, value }: { ok: boolean; label: string; value: string }) { + return ( +
  • +

    {label}

    +

    {value}

    +

    {ok ? "Pass" : "Fail"}

    +
  • + ); +} diff --git a/src/lib/auth/server.ts b/src/lib/auth/server.ts index eb2b6e8..d8fb8d4 100644 --- a/src/lib/auth/server.ts +++ b/src/lib/auth/server.ts @@ -100,6 +100,11 @@ const LOCAL_DEV_ORIGINS: string[] = [ "http://localhost:8080", "http://127.0.0.1:8080", "http://[::1]:8080", + "http://localhost:3000", + "http://127.0.0.1:3000", + "http://[::1]:3000", + "http://localhost:5173", + "http://127.0.0.1:5173", ]; const baseURL = explicitBaseURL ?? { // Include loopback hosts so dynamic baseURL resolves for local email/password diff --git a/src/lib/desk-store.ts b/src/lib/desk-store.ts index 04f496c..c75b810 100644 --- a/src/lib/desk-store.ts +++ b/src/lib/desk-store.ts @@ -43,6 +43,7 @@ type DeskState = { scan: ScanRow[]; asOf: number | null; source: string; + pausedAt: number | null; setMode: (m: DeskMode) => void; setKilled: (k: boolean) => void; setHoldings: (h: HoldingRow[]) => void; @@ -61,6 +62,7 @@ type DeskState = { ticks: TickMap; dailyPnl: number; scan: ScanRow[]; + pausedAt?: number | null; }) => void; }; @@ -72,8 +74,8 @@ function seedTicks(): TickMap { } export const useDesk = create((set, get) => ({ - mode: "auto", - killed: false, + mode: "advisory", + killed: true, holdings: DEMO_HOLDINGS, positions: [], fills: [], @@ -83,6 +85,7 @@ export const useDesk = create((set, get) => ({ scan: [], asOf: null, source: "snapshot", + pausedAt: null, setMode: (mode) => set({ mode }), setKilled: (killed) => set({ killed }), setHoldings: (holdings) => set({ holdings }), @@ -127,5 +130,6 @@ export const useDesk = create((set, get) => ({ ticks: { ...get().ticks, ...book.ticks }, dailyPnl: book.dailyPnl, scan: book.scan, + pausedAt: book.pausedAt ?? (book.killed ? get().pausedAt : null), }), })); diff --git a/src/lib/meridian/advice.ts b/src/lib/meridian/advice.ts index 7ccde0a..2cfc770 100644 --- a/src/lib/meridian/advice.ts +++ b/src/lib/meridian/advice.ts @@ -30,9 +30,56 @@ export type MarketState = { source: string; }; -export function buildAdvice(m: MarketState): AdviceCard[] { +export function buildAdvice( + m: MarketState, + meta?: { promoted?: boolean; n?: number }, +): AdviceCard[] { const cards: AdviceCard[] = []; - if (m.regime === "Stress") { + const cashClosed = m.session === "weekend" || m.session === "pre" || m.session === "post"; + const promoted = Boolean(meta?.promoted); + + if (cashClosed) { + cards.push({ + id: "spot-closed", + sleeve: "Spot", + stance: "Neutral", + title: "Cash session closed", + body: "Do not work NSE cash while the session is shut. Overnight paper is crypto-only. Not an order.", + urgency: "now", + }); + cards.push({ + id: "fut-closed", + sleeve: "Futures", + stance: "Neutral", + title: "NSE F&O waits for the open", + body: "Index futures and options are not a weekend job. Flatten leftover NSE delta in the cash session. Not an order.", + urgency: "watch", + }); + cards.push({ + id: "crypto-closed", + sleeve: "Crypto", + stance: "Neutral", + title: "Overnight farm is crypto-only", + body: "BTC/ETH/SOL paper clips are the night job. This is paper. Kite stays off. Not an order.", + urgency: "session", + }); + cards.push({ + id: "fx-closed", + sleeve: "FX", + stance: "Neutral", + title: "USDINR sits overnight", + body: "Rupee pair is a hedge, not a punch, while NSE is shut. Not an order.", + urgency: "watch", + }); + cards.push({ + id: "cmd-closed", + sleeve: "Commodity", + stance: "Neutral", + title: "Gold is ballast", + body: "MCX gold can sit. Do not start a new crude clip from a closed cash tape. Not an order.", + urgency: "watch", + }); + } else if (m.regime === "Stress") { cards.push({ id: "spot-1", sleeve: "Spot", @@ -87,7 +134,9 @@ export function buildAdvice(m: MarketState): AdviceCard[] { sleeve: "Spot", stance: "Neutral", title: "Hold quality, skip chase", - body: "Tape is two-sided. Add only where five-factor score still clears Buy and meta-prob is above 0.55. Not an order.", + body: promoted + ? "Tape is two-sided. Add only where five-factor score still clears Buy and paper meta is promoted. Not an order." + : "Tape is two-sided. Factor Buy is not a paper-model size. Meta is not promoted — do not work a 0.55 gate. Not an order.", urgency: "session", }); cards.push({ @@ -134,9 +183,11 @@ export function buildAdvice(m: MarketState): AdviceCard[] { cards.push({ id: "spot-1", sleeve: "Spot", - stance: "Long", - title: "Buy quality on dips", - body: "Calm regime. Five-factor Buy names with meta-prob above the 0.55 gate can be worked in cash. Heat still capped. Not an order.", + stance: promoted ? "Long" : "Neutral", + title: promoted ? "Buy quality on dips" : "Quality sits until promote", + body: promoted + ? "Calm regime. Five-factor Buy names with a promoted paper model can be worked in cash. Heat still capped. Not an order." + : "Calm regime. Five-factor labels can sit. Paper model is not promoted — do not treat Book Buy as model-backed and do not work a 0.55 gate. Keep farming. Not an order.", urgency: "session", }); cards.push({ diff --git a/src/lib/meridian/portfolio.ts b/src/lib/meridian/portfolio.ts index 561892d..a043368 100644 --- a/src/lib/meridian/portfolio.ts +++ b/src/lib/meridian/portfolio.ts @@ -21,6 +21,8 @@ export type HoldingReview = HoldingRow & { score: number | null; action: ActionLabel | "—"; metaProb: number; + metaLabel: string; + modelApplies: boolean; predictability: number; strength: string; note: string; @@ -109,7 +111,7 @@ export function parseHoldingsCsv(text: string): HoldingRow[] { return rows; } -export function reviewHolding(h: HoldingRow, regime: Regime): HoldingReview { +export function reviewHolding(h: HoldingRow, regime: Regime, promoted = false): HoldingReview { const u = lookup(h.symbol); const last = h.lastPrice || u?.last || h.avgCost; const invested = h.qty * h.avgCost; @@ -122,23 +124,31 @@ export function reviewHolding(h: HoldingRow, regime: Regime): HoldingReview { const score = compositeScore(parts, regime); const action = mapAction(score, regime); const clock = sessionClock(); - const metaProb = predictMetaProb({ - confidence: (score ?? 5) / 10, - confluence: 70 + (score ?? 5), - p_success: 0.5 + ((score ?? 5) - 5) / 20, - atr_pct: u?.atrPct ?? 0.02, - approx_stop_pct: 1.5 * (u?.atrPct ?? 0.02), - minutes_since_midnight: clock.minutesSinceMidnight, - minutes_to_eod_flatten: Math.max(0, clock.minutesToEod), - }); + const modelApplies = promoted; + const metaProb = modelApplies + ? predictMetaProb({ + confidence: (score ?? 5) / 10, + confluence: 70 + (score ?? 5), + p_success: 0.5 + ((score ?? 5) - 5) / 20, + atr_pct: u?.atrPct ?? 0.02, + approx_stop_pct: 1.5 * (u?.atrPct ?? 0.02), + minutes_since_midnight: clock.minutesSinceMidnight, + minutes_to_eod_flatten: Math.max(0, clock.minutesToEod), + }) + : 0; const trend = u ? (u.last > u.sma50 ? 1 : 0) + (u.sma20 > u.sma50 ? 1 : 0) : 0; - const predictability = Math.round(clamp01(metaProb * 0.7 + trend * 0.12 + (score ?? 5) / 50) * 100); + const predictability = modelApplies + ? Math.round(clamp01(metaProb * 0.7 + trend * 0.12 + (score ?? 5) / 50) * 100) + : Math.round(clamp01((score ?? 5) / 10) * 100); const strength = predictability >= 70 ? "High" : predictability >= 55 ? "Medium" : predictability >= 40 ? "Low" : "Weak"; - let note = "Not an order. Review only."; - if (action === "Strong Buy" || action === "Buy") + let note = "Not an order. Review only. Imported cash book is not the paper clip book."; + if (!modelApplies) { + note = + "Factor label only. Paper model is not promoted — do not add on dips from META. Not an order."; + } else if (action === "Strong Buy" || action === "Buy") { note = "Tape and factors still support adding on dips. Size with heat. Not an order."; - else if (action === "Hold") note = "Keep. Do not chase. Revisit if regime turns Stress. Not an order."; + } else if (action === "Hold") note = "Keep. Do not chase. Revisit if regime turns Stress. Not an order."; else if (action === "Reduce") note = "Edge is thinning. Trim into strength. Not an order."; else if (action === "Sell") note = "Factors no longer pay you to sit. Review an exit plan. Not an order."; return { @@ -151,6 +161,8 @@ export function reviewHolding(h: HoldingRow, regime: Regime): HoldingReview { score, action, metaProb, + metaLabel: modelApplies ? `${(metaProb * 100).toFixed(0)}%` : "n/a", + modelApplies, predictability, strength, note, diff --git a/src/lib/meridian/research-rank.ts b/src/lib/meridian/research-rank.ts new file mode 100644 index 0000000..f3c62ef --- /dev/null +++ b/src/lib/meridian/research-rank.ts @@ -0,0 +1,65 @@ +import type { UniverseName } from "./universe"; + +export function rankResearch(query: string, universe: UniverseName[]) { + const q = query.toLowerCase(); + const wantCrypto = /crypto|bitcoin|btc|eth|delta|binance/.test(q); + const wantFx = /usd|inr|rupee|forex|fx|dollar|hedge/.test(q); + const wantMetal = /gold|silver|copper|crude|commodit|metal|stress/.test(q); + const wantSpares = /spare|component|data.?center|cable|wire|havells|kei|polycab|ai data/.test(q); + const wantBank = /\bbank/.test(q); + const wantIt = /\bit\b|infosys|tcs|software/.test(q); + + const scored = universe.map((u) => { + let s = 0; + for (const t of u.themes) { + const t0 = t.replace(/-/g, " "); + if (q.includes(t0) || q.includes(t)) s += 4; + } + if (wantCrypto && u.assetClass === "crypto") s += 12; + if (wantFx && u.assetClass === "forex") s += 12; + if (wantMetal && u.assetClass === "commodity") s += 12; + if (wantSpares && u.themes.some((t) => /cable|power|ems|ai|electrical|spare/.test(t))) s += 10; + if (wantBank && u.themes.includes("banks")) s += 10; + if (wantIt && u.themes.includes("it-services")) s += 8; + if (wantCrypto && u.assetClass !== "crypto") s -= 8; + if (wantFx && u.assetClass !== "forex") s -= 8; + if (wantMetal && u.assetClass !== "commodity") s -= 6; + if (wantSpares && u.themes.includes("banks")) s -= 10; + return { u, s }; + }); + + const hits = scored.filter((x) => x.s >= 8).sort((a, b) => b.s - a.s); + if (!hits.length) { + return { + names: [] as Array<{ + symbol: string; + name: string; + sector: string; + score: number; + why: string; + risk: string; + sleeve: string; + }>, + reason: "No names in the desk universe match that question. Refusing a canned shortlist.", + }; + } + return { + reason: "Ranked from the desk universe.", + names: hits.slice(0, 6).map(({ u }) => ({ + symbol: u.symbol, + name: u.name, + sector: u.sector, + score: Math.round((u.quality * 0.5 + u.sentiment * 0.3 + 2) * 10) / 10, + why: u.thesis, + risk: "Desk heuristic — not Grok. Not an order.", + sleeve: + u.assetClass === "crypto" + ? "Crypto" + : u.assetClass === "forex" + ? "FX" + : u.assetClass === "commodity" + ? "Commodity" + : "Spot", + })), + }; +} diff --git a/src/lib/server/desk.ts b/src/lib/server/desk.ts index 527c442..f306417 100644 --- a/src/lib/server/desk.ts +++ b/src/lib/server/desk.ts @@ -8,6 +8,8 @@ import { seedTestDeskUser } from "@/lib/server/seed-test-user"; import { getHistoryBars, getLiveBook, overlayName, type LiveQuote } from "@/lib/server/quotes"; import { listBinanceLive } from "@/lib/server/binance-catalog"; import { SNAPSHOT } from "@/lib/meridian/tickers"; +import { rankResearch } from "@/lib/meridian/research-rank"; +import { getArtefact } from "@/lib/meridian/artefact"; import { z } from "zod"; function q(book: Awaited>, sym: string, fallback: number) { @@ -51,9 +53,10 @@ export const getMarket = createServerFn({ method: "GET" }).handler(async () => { book.binance?.length ? book.binance : (await listBinanceLive()).map((r) => ({ symbol: r.symbol, pair: r.pair, last: r.last, chg: r.chg, vol: r.vol })); + const art = getArtefact(); return { state, - advice: buildAdvice(state), + advice: buildAdvice(state, { promoted: art.promoted, n: art.n }), quotes, names, binance, @@ -61,6 +64,13 @@ export const getMarket = createServerFn({ method: "GET" }).handler(async () => { source: book.source, ok: book.ok, fail: book.fail, + meta: { + n: art.n, + auc: art.auc, + hitRate: art.hitRate, + promoted: art.promoted, + source: art.source, + }, }; }); @@ -86,6 +96,58 @@ export const resetPaperBook = createServerFn({ method: "POST" }).handler(async ( return resetEngine(); }); +export const flattenPaperBook = createServerFn({ method: "POST" }).handler(async () => { + const { flattenEngine } = await import("@/lib/server/paper-engine"); + return flattenEngine(); +}); + +export const flattenPaperClip = createServerFn({ method: "POST" }) + .validator((input: { symbol: string }) => input) + .handler(async ({ data }) => { + const { flattenClip } = await import("@/lib/server/paper-engine"); + return flattenClip(data.symbol); + }); + +export const approvePaperPending = createServerFn({ method: "POST" }) + .validator((input: { id: string; sizePct?: number }) => input) + .handler(async ({ data }) => { + const { approvePending } = await import("@/lib/server/paper-engine"); + return approvePending(data.id, data.sizePct); + }); + +export const skipPaperPending = createServerFn({ method: "POST" }) + .validator((input: { id: string }) => input) + .handler(async ({ data }) => { + const { skipPending } = await import("@/lib/server/paper-engine"); + return skipPending(data.id); + }); + +export const setPaperBlock = createServerFn({ method: "POST" }) + .validator((input: { symbol: string; blocked: boolean }) => input) + .handler(async ({ data }) => { + const { setBlocked } = await import("@/lib/server/paper-engine"); + return setBlocked(data.symbol, data.blocked); + }); + +export const setPaperWatch = createServerFn({ method: "POST" }) + .validator((input: { symbol: string; watch: boolean }) => input) + .handler(async ({ data }) => { + const { setWatched } = await import("@/lib/server/paper-engine"); + return setWatched(data.symbol, data.watch); + }); + +export const queuePaperHedge = createServerFn({ method: "POST" }) + .validator((input: { symbol: string; note: string; from?: string }) => input) + .handler(async ({ data }) => { + const { queueHedge } = await import("@/lib/server/paper-engine"); + return queueHedge(data.symbol, data.note, data.from ?? "greeks"); + }); + +export const dismissPaperHedge = createServerFn({ method: "POST" }).handler(async () => { + const { dismissHedge } = await import("@/lib/server/paper-engine"); + return dismissHedge(); +}); + export const getPaperSamples = createServerFn({ method: "GET" }).handler(async () => { const { listSamples } = await import("@/lib/server/paper-engine"); return listSamples(800); @@ -178,7 +240,7 @@ export const runResearch = createServerFn({ method: "POST" }) { role: "system", content: - "You are Meridian Final's Indian-equity research desk. Return ONLY JSON: {\"names\":[{\"symbol\",\"name\",\"sector\",\"score\",\"why\",\"risk\",\"sleeve\"}]}. Use ONLY symbols from the provided universe. score 0-10. sleeve is Spot|Futures|Options. Keep why/risk to two sentences. Not investment advice. Max 6 names.", + "You are Meridian Final's Indian-equity research desk. Return ONLY JSON: {\"names\":[{\"symbol\",\"name\",\"sector\",\"score\",\"why\",\"risk\",\"sleeve\"}],\"reason\":\"\"}. Use ONLY symbols from the provided universe. If the query does not match those names, return {\"names\":[],\"reason\":\"No names in the desk universe match that question.\"}. Never pad with unrelated banks or IT. score 0-10. sleeve is Spot|Futures|Options|Crypto|FX|Commodity. Keep why/risk to two sentences. Not investment advice. Max 6 names.", }, { role: "user", @@ -191,17 +253,25 @@ export const runResearch = createServerFn({ method: "POST" }) const body = (await res.json()) as { choices?: { message?: { content?: string } }[] }; const text = body.choices?.[0]?.message?.content ?? ""; const json = extractJson(text); - const names = (json?.names ?? []) as ResearchName[]; - if (!names.length) return heuristicResearch(data.query); + const allowed = new Set(UNIVERSE.map((u) => u.symbol)); + const names = ((json?.names ?? []) as ResearchName[]).filter((n) => allowed.has(n.symbol)); + if (!names.length) { + return { + ok: true as const, + source: "grok" as const, + names: [], + reason: json && Array.isArray(json.names) ? "No names in the desk universe match that question. Refusing a canned shortlist." : "Grok returned no matching names.", + }; + } const sql = await getSql(); await sql` insert into research_runs (user_id, query, result_json) values (${context.userId}, ${data.query}, ${JSON.stringify(names)}) `; - return { ok: true as const, source: "grok" as const, names: names.slice(0, 6) }; + return { ok: true as const, source: "grok" as const, names: names.slice(0, 6), reason: "Ranked from the desk universe." }; }); -function extractJson(text: string): { names?: ResearchName[] } | null { +function extractJson(text: string): { names?: ResearchName[]; reason?: string } | null { const start = text.indexOf("{"); const end = text.lastIndexOf("}"); if (start < 0 || end <= start) return null; @@ -213,71 +283,6 @@ function extractJson(text: string): { names?: ResearchName[] } | null { } function heuristicResearch(query: string) { - const q = query.toLowerCase(); - const scored = UNIVERSE.map((u) => { - let s = 0; - for (const t of u.themes) if (q.includes(t.replace(/-/g, " ")) || q.includes(t)) s += 3; - const keys = [ - "data center", - "datacenter", - "ai", - "cable", - "power", - "spare", - "component", - "ems", - "cooling", - "grid", - "bitcoin", - "crypto", - "gold", - "silver", - "crude", - "copper", - "forex", - "dollar", - "rupee", - "yen", - ]; - for (const k of keys) - if ( - q.includes(k) && - u.themes.some( - (t) => - t.includes(k.split(" ")[0]!) || - t.includes("ai") || - t.includes("power") || - t.includes("cables") || - t.includes("ems") || - t.includes("crypto") || - t.includes("commodity") || - t.includes("forex"), - ) - ) - s += 2; - if (q.includes("bank") && u.themes.includes("banks")) s += 4; - if (q.includes("it") && u.themes.includes("it-services")) s += 3; - if ((q.includes("crypto") || q.includes("bitcoin") || q.includes("btc")) && u.assetClass === "crypto") s += 6; - if ((q.includes("forex") || q.includes("dollar") || q.includes("rupee") || q.includes("fx")) && u.assetClass === "forex") - s += 6; - if ( - (q.includes("gold") || q.includes("crude") || q.includes("silver") || q.includes("commodit") || q.includes("copper")) && - u.assetClass === "commodity" - ) - s += 6; - s += u.quality / 4 + u.sentiment / 5; - return { u, s }; - }) - .sort((a, b) => b.s - a.s) - .slice(0, 6) - .map(({ u }) => ({ - symbol: u.symbol, - name: u.name, - sector: u.sector, - score: Math.round((u.quality * 0.5 + u.sentiment * 0.3 + 2) * 10) / 10, - why: u.thesis, - risk: "Valuation and execution can slip; this is a research shortlist, not an order.", - sleeve: u.assetClass === "crypto" ? "Crypto" : u.assetClass === "forex" ? "FX" : u.assetClass === "commodity" ? "Commodity" : "Spot", - })); - return { ok: true as const, source: "desk" as const, names: scored }; + const ranked = rankResearch(query, UNIVERSE); + return { ok: true as const, source: "desk" as const, names: ranked.names, reason: ranked.reason }; } diff --git a/src/lib/server/paper-engine.ts b/src/lib/server/paper-engine.ts index b1c2103..acf7a5d 100644 --- a/src/lib/server/paper-engine.ts +++ b/src/lib/server/paper-engine.ts @@ -53,6 +53,7 @@ import { parseFo, } from "@/lib/meridian/fo-contracts"; import { formatIstStamp } from "@/lib/utils"; +import { englishReason, quoteSource, PENDING_MS } from "@/lib/ux-copy"; type QuoteLabel = "live" | "delayed" | "model"; @@ -79,6 +80,37 @@ type ScanRow = { px: number; }; +export type PendingClip = { + id: string; + ts: number; + expiresAt: number; + symbol: string; + side: "long" | "short"; + qty: number; + sizePct: number; + px: number; + mid: number; + reason: string; + sleeve: DeskSleeve; + metaProb: number; + stopPct: number; + score: number; + confidence: number; + confluence: number; + pSuccess: number; + atrPct: number; + features: FeatureVec; + expiry?: string; + strike?: number; + right?: string; + quoteLabel?: QuoteLabel; + timeout: "auto-skip" | "auto-send"; +}; + +export type CloseStats = { n: number; timeStop: number; quality: number }; + +export type QueuedHedge = { symbol: string; note: string; ts: number; from: string }; + function cryptoStub(sym: string, last: number): UniverseName { const u = UNIVERSE.find((x) => x.symbol === sym); if (u) return { ...u, last: last || u.last }; @@ -284,6 +316,14 @@ export type PaperBook = { samples: number; lastTick: number; ticksRun: number; + pausedAt?: number | null; + pending: PendingClip[]; + blocked: string[]; + watched: string[]; + lastDecision: string; + heat: { farm: number; pnl: number }; + closeStats: CloseStats; + queuedHedge: QueuedHedge | null; meta?: { n: number; auc: number; @@ -306,12 +346,13 @@ type Engine = PaperBook & { lastRetrainN: number; delayed: Record; foMeta: Record; + blockedMap: Record; }; const g = globalThis as typeof globalThis & { __meridianPaper?: { timer: ReturnType; eng: Engine; rev: number }; }; -const ENGINE_REV = 15; +const ENGINE_REV = 17; function seedTicks() { const t: Record = {}; @@ -323,8 +364,8 @@ function seedTicks() { function emptyEngine(): Engine { const ticks = seedTicks(); return { - mode: "auto", - killed: false, + mode: "advisory", + killed: true, positions: [], fills: [], ticks, @@ -334,6 +375,14 @@ function emptyEngine(): Engine { samples: 0, lastTick: 0, ticksRun: 0, + pausedAt: Date.now(), + pending: [], + blocked: [], + watched: [], + lastDecision: "Engine paused. Start paper to send clips. Signals still scan.", + heat: { farm: 0, pnl: 0 }, + closeStats: { n: 0, timeStop: 0, quality: 0 }, + queuedHedge: null, cooldownUntil: {}, live: {}, liveFeed: {}, @@ -344,6 +393,7 @@ function emptyEngine(): Engine { lastRetrainN: 0, delayed: {}, foMeta: {}, + blockedMap: {}, }; } @@ -364,6 +414,126 @@ function pnlOf(pos: Position, px: number) { return (px - pos.entryPrice) * pos.qty * dir; } +function bumpClose(eng: Engine, reason: string) { + eng.closeStats.n += 1; + if (reason === "time_stop") eng.closeStats.timeStop += 1; + else eng.closeStats.quality += 1; +} + +function heatOf(positions: Position[]) { + return { + farm: positions.filter((p) => (p.sleeve ?? "farm") === "farm").reduce((a, p) => a + p.sizePct, 0), + pnl: positions.filter((p) => p.sleeve === "pnl").reduce((a, p) => a + p.sizePct, 0), + }; +} + +function lastDecisionOf(eng: Engine): string { + if (eng.killed) { + const t = eng.pausedAt ? formatIstStamp(eng.pausedAt) : ""; + return `Engine paused${t ? ` at ${t}` : ""}. Exits still run. No new paper clips.`; + } + if (eng.mode === "advisory") { + const hit = eng.scan.find((s) => s.action.startsWith("Would")); + if (hit) return `${hit.action} ${hit.symbol.replace(/^\w+:/, "")} — ${hit.reason}. Not sent.`; + return "Signals only. Last scan is live. Nothing is sent until Start paper."; + } + const p = eng.pending[0]; + if (p) { + const left = Math.max(0, Math.round((p.expiresAt - Date.now()) / 1000)); + return `Pending ${p.side === "long" ? "BUY" : "SELL"} ${p.symbol} — Approve, Skip, or ${p.timeout} in ${left}s.`; + } + const hit = eng.scan.find((s) => s.action === "BUY" || s.action === "SELL"); + if (hit) return `${hit.action} ${hit.symbol.replace(/^\w+:/, "")} — ${hit.reason}.`; + return "No clip to send this scan."; +} + +function ensureEngineExtras(eng: Engine) { + eng.pending ??= []; + eng.blocked ??= []; + eng.watched ??= []; + eng.closeStats ??= { n: 0, timeStop: 0, quality: 0 }; + eng.queuedHedge ??= null; + eng.blockedMap ??= Object.fromEntries((eng.blocked ?? []).map((s) => [s, true])); + eng.heat ??= { farm: 0, pnl: 0 }; + eng.lastDecision ??= ""; + eng.pausedAt ??= eng.killed ? Date.now() : null; +} + +type OpenSpec = { + symbol: string; + side: "long" | "short"; + qty: number; + sizePct: number; + px: number; + mid: number; + reason: string; + sleeve: DeskSleeve; + metaProb: number; + stopPct: number; + score: number; + confidence: number; + confluence: number; + pSuccess: number; + atrPct: number; + features: FeatureVec; + expiry?: string; + strike?: number; + right?: string; + quoteLabel?: QuoteLabel; +}; + +function openFromSpec(eng: Engine, spec: OpenSpec, now: number) { + if (eng.positions.some((p) => p.symbol === spec.symbol)) return; + if (eng.blockedMap[spec.symbol]) return; + const extra = foFields(spec.symbol, eng.foMeta[spec.symbol]); + const pos: Position = { + symbol: spec.symbol, + side: spec.side, + entryPrice: spec.px, + entryMid: spec.mid, + entryTs: now, + stopPct: spec.stopPct, + sizePct: spec.sizePct, + metaProb: spec.metaProb, + highSinceEntry: spec.mid, + lowSinceEntry: spec.mid, + qty: spec.qty, + reasonOpen: spec.reason, + confidence: spec.confidence, + confluence: spec.confluence, + pSuccess: spec.pSuccess, + atrPct: spec.atrPct, + score: spec.score, + expiry: spec.expiry ?? extra.expiry, + strike: spec.strike ?? extra.strike, + right: spec.right ?? extra.right, + quoteLabel: spec.quoteLabel, + sleeve: spec.sleeve, + costBps: roundTripBps(clsFor(spec.symbol, eng)), + features: spec.features, + }; + eng.positions.push(pos); + const fill: Fill = { + id: `${now}-${spec.symbol}-${spec.sleeve}-${spec.side}-${spec.qty}`, + ts: now, + symbol: spec.symbol, + side: spec.side === "long" ? "BUY" : "SELL", + qty: spec.qty, + price: spec.px, + reason: englishReason(spec.reason, { + sleeve: spec.sleeve, + quote: quoteSource(spec.quoteLabel, eng.liveFeed[spec.symbol]), + }), + quoteLabel: spec.quoteLabel, + sleeve: spec.sleeve, + expiry: pos.expiry, + strike: pos.strike, + right: pos.right, + }; + eng.fills = [fill, ...eng.fills].slice(0, 400); + void persistFill(fill, pos.metaProb, null); +} + async function persistFill(f: Fill, metaProb: number, pnl: number | null) { try { const sql = await getSql(); @@ -553,8 +723,18 @@ async function tick() { const clock = sessionClock(); const now = Date.now(); - const paused = eng.mode === "advisory" || eng.killed; + const freezeScan = eng.killed && eng.mode !== "advisory"; let positions = [...eng.positions]; + ensureEngineExtras(eng); + + eng.pending = (eng.pending ?? []).filter((p) => { + if (now < p.expiresAt) return true; + if (p.timeout === "auto-send" && eng.mode === "auto" && !eng.killed) { + openFromSpec(eng, p, now); + } + return false; + }); + positions = [...eng.positions]; const still: Position[] = []; for (const pos of positions) { @@ -570,10 +750,6 @@ async function tick() { continue; } const mid = livePx > 0 ? livePx : (eng.ticks[pos.symbol] ?? pos.entryPrice); - if (paused && !(sessionFlat && nseHours)) { - still.push(pos); - continue; - } const prof = profileOf(pos.sleeve); const intent = sessionFlat ? { @@ -586,7 +762,7 @@ async function tick() { : manage({ ...pos }, mid, now, allHours ? 999 : clock.minutesToEod, prof); if (intent.action === "SELL") { const cls = clsFor(pos.symbol, eng); - const closeSide = pos.side === "short" ? "BUY" : "SELL"; + const closeSide: Fill["side"] = pos.side === "short" ? "BUY" : "SELL"; const px = fillFromMid(mid, closeSide === "BUY" ? "buy" : "sell", cls); const pnl = pnlOf(pos, px); const label = quoteLabelOf(eng.liveFeed[pos.symbol], eng.delayed[pos.symbol]); @@ -598,7 +774,10 @@ async function tick() { side: closeSide, qty: pos.qty, price: px, - reason: `${intent.reason}:${pos.side}:${label}`, + reason: englishReason(`${intent.reason}:${pos.side}`, { + sleeve: pos.sleeve, + quote: quoteSource(label, eng.liveFeed[pos.symbol]), + }), quoteLabel: label, sleeve: pos.sleeve, ...extra, @@ -620,6 +799,7 @@ async function tick() { netRet: fwdRet, }); eng.samples += 1; + bumpClose(eng, intent.reason); await persistFill(fill, pos.metaProb, pnl); await persistSample({ id: fill.id, @@ -662,6 +842,7 @@ async function tick() { } } positions = still; + eng.positions = positions; const scan: ScanRow[] = []; const art = getArtefact(); @@ -673,6 +854,8 @@ async function tick() { const liveLast = eng.live[sym]; if (!(liveLast > 0)) return null; if (positions.some((p) => p.symbol === sym)) return null; + if (eng.blockedMap[sym]) return null; + if (eng.pending.some((p) => p.symbol === sym)) return null; const u = nameStub(sym, liveLast); const parts = factorParts(u); const score = compositeScore(parts, "Calm") ?? 6; @@ -693,7 +876,7 @@ async function tick() { }, { dailyPnl: eng.dailyPnl, - killed: eng.killed, + killed: eng.mode === "advisory" ? false : eng.killed, live: false, nOpen: mine.length, cooldownUntil: eng.cooldownUntil, @@ -713,15 +896,26 @@ async function tick() { }>; for (const row of ranked) { + const long = row.intent.action === "BUY"; + const short = row.intent.action === "SELL" && row.intent.reason === "fade_short"; + let action: string = row.intent.action; + if (eng.mode === "advisory") { + if (long) action = "Would BUY"; + else if (short) action = "Would SELL"; + } else if (eng.mode === "paper" && (long || short)) { + action = long ? "Pending BUY" : "Pending SELL"; + } scan.push({ symbol: `${sleeve}:${row.sym}`, - action: row.intent.action, - reason: `${sleeve}:${row.intent.reason}`, + action, + reason: englishReason(row.intent.reason, { sleeve }), metaProb: row.intent.metaProb, px: row.px, }); } + if (eng.mode === "advisory") return; + ranked.sort((a, b) => Math.abs(b.intent.metaProb - 0.5) - Math.abs(a.intent.metaProb - 0.5)); let runningHeat = heat; let nOpen = mine.length; @@ -742,56 +936,55 @@ async function tick() { if (qty <= 0) continue; const extra = foFields(row.sym, eng.foMeta[row.sym]); const label = quoteLabelOf(eng.liveFeed[row.sym], eng.delayed[row.sym]); - const pos: Position = { + const spec: OpenSpec = { symbol: row.sym, side, - entryPrice: px, - entryMid: mid, - entryTs: now, - stopPct: long ? row.intent.stopPct : 0.012, + qty, sizePct, + px, + mid, + reason: long ? row.intent.reason : "fade_short", + sleeve, metaProb: row.intent.metaProb, - highSinceEntry: mid, - lowSinceEntry: mid, - qty, - reasonOpen: long ? row.intent.reason : "fade_short", + stopPct: long ? row.intent.stopPct : 0.012, + score: row.score, confidence: row.f.confidence, confluence: row.f.confluence, pSuccess: row.f.p_success, atrPct: row.f.atr_pct, - score: row.score, + features: row.f, expiry: extra.expiry, strike: extra.strike, right: extra.right, quoteLabel: label, - sleeve, - costBps: roundTripBps(cls), - features: row.f, }; - positions.push(pos); + if (eng.mode === "paper") { + eng.pending.push({ + ...spec, + id: `${now}-${row.sym}-${sleeve}`, + ts: now, + expiresAt: now + PENDING_MS, + timeout: "auto-skip", + }); + runningHeat += sizePct; + nOpen += 1; + continue; + } + openFromSpec(eng, spec, now); + positions = [...eng.positions]; runningHeat += sizePct; nOpen += 1; - const fill: Fill = { - id: `${now}-${row.sym}-${sleeve}-${side}-${qty}`, - ts: now, - symbol: row.sym, - side: side === "long" ? "BUY" : "SELL", - qty, - price: px, - reason: `${sleeve}:${pos.reasonOpen}:${label}`, - quoteLabel: label, - sleeve, - ...extra, - }; - eng.fills = [fill, ...eng.fills].slice(0, 400); - await persistFill(fill, pos.metaProb, null); } } - if ((eng.mode === "auto" || eng.mode === "paper") && !eng.killed) { + if (!eng.killed || eng.mode === "advisory") { await openSleeve("farm", farmWatch(eng.live, eng.liveFeed, clock.openSession), FARM_PROFILE); await openSleeve("pnl", pnlWatch(eng.live, eng.liveFeed, clock.openSession), PNL_PROFILE); - if (eng.samples - eng.lastRetrainN >= 50 || (art.source === "synth" && now - eng.lastRetrainAt > 60_000)) { + if ( + !eng.killed && + (eng.mode === "auto" || eng.mode === "paper") && + (eng.samples - eng.lastRetrainN >= 50 || (art.source === "synth" && now - eng.lastRetrainAt > 60_000)) + ) { const next = await retrainFromJsonl(); if (next) { eng.lastRetrainAt = now; @@ -800,8 +993,9 @@ async function tick() { } } - eng.positions = positions; - if (!paused) eng.scan = scan; + eng.heat = heatOf(eng.positions); + if (!freezeScan) eng.scan = scan; + eng.lastDecision = lastDecisionOf(eng); try { await mkdir(DATA_DIR, { recursive: true }); await writeFile( @@ -835,6 +1029,7 @@ async function tick() { export function startPaperEngine() { if (typeof window !== "undefined") return; if (g.__meridianPaper?.rev === ENGINE_REV) { + ensureEngineExtras(g.__meridianPaper.eng); g.__meridianPaper.eng.liveFeed ??= {}; g.__meridianPaper.eng.delayed ??= {}; g.__meridianPaper.eng.foMeta ??= {}; @@ -847,10 +1042,7 @@ export function startPaperEngine() { } if (g.__meridianPaper) clearInterval(g.__meridianPaper.timer); const eng = g.__meridianPaper?.eng ?? emptyEngine(); - if (!g.__meridianPaper?.eng) { - eng.mode = "auto"; - eng.killed = false; - } + ensureEngineExtras(eng); eng.liveFeed ??= {}; eng.delayed ??= {}; eng.foMeta ??= {}; @@ -886,7 +1078,11 @@ export function getEngine(): Engine { export function snapshotBook(): PaperBook { const e = getEngine(); + ensureEngineExtras(e); const art = getArtefact(); + e.blocked = Object.keys(e.blockedMap).filter((k) => e.blockedMap[k]); + e.heat = heatOf(e.positions); + e.lastDecision = lastDecisionOf(e); return { mode: e.mode, killed: e.killed, @@ -898,6 +1094,14 @@ export function snapshotBook(): PaperBook { samples: e.samples, lastTick: e.lastTick, ticksRun: e.ticksRun, + pausedAt: e.killed ? (e.pausedAt ?? e.lastTick) : null, + pending: e.pending, + blocked: e.blocked, + watched: e.watched, + lastDecision: e.lastDecision, + heat: e.heat, + closeStats: e.closeStats, + queuedHedge: e.queuedHedge, meta: { n: art.n, auc: art.auc, @@ -912,7 +1116,125 @@ export function snapshotBook(): PaperBook { export function setEngineFlags(patch: { mode?: PaperBook["mode"]; killed?: boolean }) { const e = getEngine(); if (patch.mode) e.mode = patch.mode; - if (patch.killed != null) e.killed = patch.killed; + if (patch.killed != null) { + e.killed = patch.killed; + e.pausedAt = patch.killed ? Date.now() : null; + } + return snapshotBook(); +} + +export function flattenEngine() { + const e = getEngine(); + const now = Date.now(); + const still: Position[] = []; + for (const pos of e.positions) { + const mid = e.live[pos.symbol] || e.ticks[pos.symbol] || pos.entryPrice; + const closeSide: Fill["side"] = pos.side === "short" ? "BUY" : "SELL"; + const label = quoteLabelOf(e.liveFeed[pos.symbol], e.delayed[pos.symbol]); + const extra = foFields(pos.symbol, e.foMeta[pos.symbol]); + const fill: Fill = { + id: `${now}-${pos.symbol}-flat`, + ts: now, + symbol: pos.symbol, + side: closeSide, + qty: pos.qty, + price: mid, + reason: englishReason("flatten", { sleeve: pos.sleeve, quote: quoteSource(label, e.liveFeed[pos.symbol]) }), + quoteLabel: label, + sleeve: pos.sleeve, + ...extra, + }; + e.fills = [fill, ...e.fills].slice(0, 400); + const dir = pos.side === "short" ? -1 : 1; + e.dailyPnl += (mid - pos.entryPrice) * pos.qty * dir; + bumpClose(e, "flatten"); + } + e.positions = still; + e.pending = []; + return snapshotBook(); +} + +export function flattenClip(symbol: string) { + const e = getEngine(); + const pos = e.positions.find((p) => p.symbol === symbol); + if (!pos) return snapshotBook(); + const now = Date.now(); + const mid = e.live[pos.symbol] || e.ticks[pos.symbol] || pos.entryPrice; + const closeSide: Fill["side"] = pos.side === "short" ? "BUY" : "SELL"; + const label = quoteLabelOf(e.liveFeed[pos.symbol], e.delayed[pos.symbol]); + const extra = foFields(pos.symbol, e.foMeta[pos.symbol]); + e.fills = [ + { + id: `${now}-${pos.symbol}-flat1`, + ts: now, + symbol: pos.symbol, + side: closeSide, + qty: pos.qty, + price: mid, + reason: englishReason("flatten", { sleeve: pos.sleeve, quote: quoteSource(label, e.liveFeed[pos.symbol]) }), + quoteLabel: label, + sleeve: pos.sleeve, + ...extra, + }, + ...e.fills, + ].slice(0, 400); + const dir = pos.side === "short" ? -1 : 1; + e.dailyPnl += (mid - pos.entryPrice) * pos.qty * dir; + e.positions = e.positions.filter((p) => p !== pos); + bumpClose(e, "flatten"); + return snapshotBook(); +} + +export function approvePending(id: string, sizePct?: number) { + const e = getEngine(); + const p = e.pending.find((x) => x.id === id); + if (!p) return snapshotBook(); + if (sizePct && sizePct > 0) { + p.sizePct = sizePct; + p.qty = qtyFor(p.symbol, p.px, sizePct, e.ticks); + } + e.pending = e.pending.filter((x) => x.id !== id); + openFromSpec(e, p, Date.now()); + return snapshotBook(); +} + +export function skipPending(id: string) { + const e = getEngine(); + e.pending = e.pending.filter((x) => x.id !== id); + return snapshotBook(); +} + +export function setBlocked(symbol: string, blocked: boolean) { + const e = getEngine(); + const sym = symbol.toUpperCase(); + e.blockedMap[sym] = blocked; + if (!blocked) delete e.blockedMap[sym]; + e.blocked = Object.keys(e.blockedMap).filter((k) => e.blockedMap[k]); + if (blocked) e.pending = e.pending.filter((p) => p.symbol !== sym); + return snapshotBook(); +} + +export function setWatched(symbol: string, watch: boolean) { + const e = getEngine(); + const sym = symbol.toUpperCase(); + const set = new Set(e.watched); + if (watch) set.add(sym); + else set.delete(sym); + e.watched = [...set]; + return snapshotBook(); +} + +export function queueHedge(symbol: string, note: string, from = "greeks") { + const e = getEngine(); + const sym = symbol.toUpperCase(); + e.watched = [...new Set([...e.watched, sym])]; + e.queuedHedge = { symbol: sym, note, ts: Date.now(), from }; + return snapshotBook(); +} + +export function dismissHedge() { + const e = getEngine(); + e.queuedHedge = null; return snapshotBook(); } @@ -949,6 +1271,7 @@ export async function listSamples(limit = 500): Promise< reason_close: string; meta_prob: number; score: number | null; + hold_class: "time-stop" | "quality" | "other"; }> > { const sql = await getSql(); @@ -996,6 +1319,12 @@ export async function listSamples(limit = 500): Promise< reason_close: r.reason_close, meta_prob: Number(r.meta_prob), score: r.score == null ? null : Number(r.score), + hold_class: + r.reason_close === "time_stop" + ? ("time-stop" as const) + : r.reason_close === "stop" || r.reason_close === "take_profit" + ? ("quality" as const) + : ("other" as const), }; }); } diff --git a/src/lib/ux-copy.ts b/src/lib/ux-copy.ts new file mode 100644 index 0000000..2d283e9 --- /dev/null +++ b/src/lib/ux-copy.ts @@ -0,0 +1,94 @@ +import { PROMOTE_MIN_AUC, PROMOTE_MIN_HIT, PROMOTE_MIN_N } from "@/lib/meridian/kelly"; +import { FARM_PROFILE, PNL_PROFILE } from "@/lib/meridian/decision"; + +export const MODE_LABEL: Record<"advisory" | "paper" | "auto", string> = { + advisory: "Signals", + paper: "Paper", + auto: "Paper auto-send", +}; + +export const PENDING_MS = 15_000; + +export function quoteSource(label?: string, feed?: string) { + if (label === "delayed") return "quote:delayed"; + if (label === "model") return "quote:model"; + if (feed?.toLowerCase().includes("binance")) return "quote:binance"; + if (feed?.toLowerCase().includes("nse") || feed?.toLowerCase().includes("yahoo")) return "quote:nse"; + if (label === "live") return "quote:print"; + return "quote:print"; +} + +export function englishReason(raw: string, opts?: { sleeve?: string; side?: string; quote?: string }) { + const q = opts?.quote ?? ""; + const map: Record = { + time_stop: "Farm time-stop — 90s barrier", + fade_short: "Farm fade on the short side", + passed_gates: "Farm passed gates", + not_promoted: "PnL sleeve waiting on hit rate / AUC gates", + cooldown: "Cooldown — same name too soon", + nse_session_closed: "NSE session closed — flattened", + night_crypto_only: "Cash session closed — crypto only", + stop: "Stop hit", + take_profit: "Take-profit", + flatten: "Flattened by operator", + skipped_timeout: "Skipped — pending timed out", + skipped_operator: "Skipped by operator", + }; + const parts = raw.split(":"); + const key = parts.find((p) => map[p]) ?? parts[0] ?? raw; + const head = map[key] ?? raw.replace(/:live\b/g, "").replace(/_/g, " "); + const sleeve = + opts?.sleeve === "pnl" ? "PnL sleeve. " : opts?.sleeve === "farm" ? "Farm sleeve. " : ""; + return `${sleeve}${head}${q ? ` (${q})` : ""}`.trim(); +} + +export function promotionLines(meta?: { + n?: number; + auc?: number; + hitRate?: number; + promoted?: boolean; + source?: string; +}) { + const n = meta?.n ?? 0; + const auc = meta?.auc ?? 0; + const hit = meta?.hitRate ?? 0; + const promoted = Boolean(meta?.promoted); + const nOk = n >= PROMOTE_MIN_N; + const aucOk = auc >= PROMOTE_MIN_AUC; + const hitOk = hit > PROMOTE_MIN_HIT; + if (promoted) { + return { + headline: "Ready to promote the PnL sleeve.", + body: `n ${n.toLocaleString("en-IN")} · AUC ${auc.toFixed(3)} · hit ${(hit * 100).toFixed(0)}%. Size real paper P&L only if heat is clear.`, + nOk, + aucOk, + hitOk, + promoted: true, + }; + } + const blockers = [ + !nOk ? `n ${n.toLocaleString("en-IN")} (need ${PROMOTE_MIN_N.toLocaleString("en-IN")})` : null, + !aucOk ? `AUC ${auc.toFixed(3)} (need ${PROMOTE_MIN_AUC})` : null, + !hitOk ? `hit ${(hit * 100).toFixed(0)}% (need ${Math.round(PROMOTE_MIN_HIT * 100)}%)` : null, + ].filter(Boolean); + return { + headline: "Not ready to promote.", + body: `${blockers.join(". ")}. PnL sleeve stays flat. Keep paper farming. Do not treat Book Buy as model-backed.`, + nOk, + aucOk, + hitOk, + promoted: false, + }; +} + +export function heatLine(farm: number, pnl: number) { + return `Farm heat ${(farm * 100).toFixed(0)}% of ${(FARM_PROFILE.MAX_HEAT * 100).toFixed(0)}%. PnL heat ${(pnl * 100).toFixed(0)}% of ${(PNL_PROFILE.MAX_HEAT * 100).toFixed(0)}%.`; +} + +export function englishAuthError(msg: string, origin?: string) { + if (/invalid origin/i.test(msg)) { + const here = origin || (typeof window !== "undefined" ? window.location.origin : ""); + return `This page's host is not trusted for sign-in. Use http://localhost:3000 or http://127.0.0.1:3000 — same app, either loopback is fine. You are on ${here || "an unknown host"}.`; + } + return msg; +} diff --git a/src/routes/auto.tsx b/src/routes/auto.tsx index a736f07..9cbc6e2 100644 --- a/src/routes/auto.tsx +++ b/src/routes/auto.tsx @@ -1,18 +1,32 @@ +import { useState } from "react"; import { createFileRoute } from "@tanstack/react-router"; import { useQuery } from "@tanstack/react-query"; import { DeskShell } from "@/components/desk-shell"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { resetDeskPaper, setDeskKilled, setDeskMode } from "@/components/auto-engine"; +import { Confirm } from "@/components/confirm"; +import { PromotionStrip } from "@/components/promotion-strip"; +import { + resetDeskPaper, + setDeskMode, + flattenDeskClip, + approveDeskPending, + skipDeskPending, + dismissDeskHedge, +} from "@/components/auto-engine"; import { useDesk } from "@/lib/desk-store"; import { inr, formatIstStamp } from "@/lib/utils"; import { PAPER_BUDGET, FARM_PROFILE, PNL_PROFILE } from "@/lib/meridian/decision"; -import { PROMOTE_MIN_N } from "@/lib/meridian/kelly"; import { getPaperBook, getPaperSamples } from "@/lib/server/desk"; +import { MODE_LABEL, quoteSource, heatLine, PENDING_MS } from "@/lib/ux-copy"; +import { useCurrentUserState } from "@/lib/auth/use-current-user"; +import { toast } from "sonner"; export const Route = createFileRoute("/auto")({ component: AutoPage }); function AutoPage() { + const { user, isPending } = useCurrentUserState(); + const operator = Boolean(user); const mode = useDesk((s) => s.mode); const killed = useDesk((s) => s.killed); const positions = useDesk((s) => s.positions); @@ -20,7 +34,10 @@ function AutoPage() { const ticks = useDesk((s) => s.ticks); const dailyPnl = useDesk((s) => s.dailyPnl); const scan = useDesk((s) => s.scan); + const pausedAt = useDesk((s) => s.pausedAt); const paper = useQuery({ queryKey: ["paper"], queryFn: () => getPaperBook(), refetchInterval: 2500 }); + const [askReset, setAskReset] = useState(false); + const [sizeEdits, setSizeEdits] = useState>({}); const mtm = positions.reduce((a, p) => { const px = ticks[p.symbol] ?? p.entryPrice; @@ -28,56 +45,122 @@ function AutoPage() { return a + (px - p.entryPrice) * p.qty * dir; }, 0); + const pending = paper.data?.pending ?? []; + const heat = paper.data?.heat ?? { farm: 0, pnl: 0 }; + const lastDecision = paper.data?.lastDecision ?? "Waiting on the first scan."; + const closeStats = paper.data?.closeStats; + const queued = paper.data?.queuedHedge; + async function downloadSamples() { const rows = await getPaperSamples(); const cols = [ - "id", "symbol", "side", "qty", "entry", "exit", "pnl", - "opened_ist", "closed_ist", "hold_sec", "fwd_ret", - "reason_open", "reason_close", "meta_prob", "score", "ts_open", "ts_close", + "id", + "symbol", + "side", + "qty", + "entry", + "exit", + "pnl", + "opened_ist", + "closed_ist", + "hold_sec", + "hold_class", + "fwd_ret", + "reason_open", + "reason_close", + "meta_prob", + "score", + "ts_open", + "ts_close", ]; const esc = (v: unknown) => { const s = v == null ? "" : String(v); return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; }; - const csv = [cols.join(","), ...rows.map((r) => cols.map((c) => esc((r as Record)[c])).join(","))].join("\n"); + const header = `# Fit set from paper_samples (closed clips). Not live quotes. Filter quality vs time-stop via hold_class. On-screen n ${paper.data?.samples ?? rows.length}; artefact n ${paper.data?.meta?.n ?? 0}.`; + const csv = [header, cols.join(","), ...rows.map((r) => cols.map((c) => esc((r as Record)[c])).join(","))].join("\n"); const blob = new Blob([csv], { type: "text/csv" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); - a.download = `meridian-paper-samples-${Date.now()}.csv`; + a.download = `meridian-fit-set-${Date.now()}.csv`; a.click(); } + function needOp() { + if (!operator) { + toast.message("Guest view only. Sign in to change the shared book."); + return false; + } + return true; + } + return (
    -

    Auto trade

    -

    Overnight paper loop

    +

    Auto

    +

    Paper desk

    - Shared paper book {inr(PAPER_BUDGET)}. Farm sleeve labels clips (time barrier {FARM_PROFILE.TIME_STOP_SEC}s, - costs on fills). PnL sleeve is Kelly-sized, no time-stop, and stays flat until paper meta - beats 0.55 AUC and 52% hit rate on ≥ {PROMOTE_MIN_N.toLocaleString("en-IN")} samples. NSE cash/F&O only in the - cash session; crypto is the overnight farm plus a BTC/ETH/SOL satellite. Paper only — Kite stays off. + Shared paper book {inr(PAPER_BUDGET)}. Signals proposes. Paper waits for Approve / Skip ({PENDING_MS / 1000}s auto-skip). + Paper auto-send fills on its own. Live Kite stays locked. Farm time-stop {FARM_PROFILE.TIME_STOP_SEC}s. PnL sleeve stays + flat until promotion gates pass.

    -
    +
    + Mirrors header {(["advisory", "paper", "auto"] as const).map((m) => ( - ))} - - -
    + + +
    +

    Last decision

    +

    {lastDecision}

    +

    {heatLine(heat.farm, heat.pnl)}

    + {killed && ( +

    + PAUSED{pausedAt ? ` at ${formatIstStamp(pausedAt)}` : ""}. Exits still run. BUY is not live. +

    + )} +
    + + {queued && ( +
    +
    +

    Queued from {queued.from}

    +

    + {queued.symbol} — {queued.note} +

    +
    + +
    + )} +

    Budget

    @@ -95,70 +178,158 @@ function AutoPage() {

    = 0 ? "text-up" : "text-down"}`}>{inr(dailyPnl)}

    -

    Meta

    -

    {paper.data?.meta?.promoted ? "armed" : "farming"}

    +

    Samples

    +

    {paper.data?.samples ?? 0}

    - n {paper.data?.meta?.n ?? 0} · auc {(paper.data?.meta?.auc ?? 0).toFixed(3)} · hit{" "} - {((paper.data?.meta?.hitRate ?? 0) * 100).toFixed(0)}% · {paper.data?.meta?.source ?? "synth"} + Artefact n {paper.data?.meta?.n ?? 0} + {(paper.data?.samples ?? 0) !== (paper.data?.meta?.n ?? 0) + ? " — counts differ; CSV is the fit set, artefact is the last fit." + : ""}

    +
    +
    +

    Action center

    + + {mode === "paper" ? "Timeout auto-skip 15s" : mode === "auto" ? "Auto-send — no pending" : "Signals — not sent"} + +
    + {pending.length === 0 ? ( +

    + {mode === "paper" + ? "No pending clip. Farm proposals wait here for Approve, Skip, or Size." + : mode === "auto" + ? "Paper auto-send fills without a queue. Switch to Paper for Approve / Skip." + : "Signals only. Would-be entries sit on Last scan. Start paper to send."} +

    + ) : ( +
      + {pending.map((p) => { + const left = Math.max(0, Math.round((p.expiresAt - Date.now()) / 1000)); + const size = sizeEdits[p.id] ?? p.sizePct; + return ( +
    • +
      + {p.side === "short" ? "Pending SELL" : "Pending BUY"} + {p.symbol} + + {p.qty} @ {p.px.toFixed(2)} · {(size * 100).toFixed(1)}% + + + {p.timeout} in {left}s + +
      +

      {p.reason}

      +
      + + + +
      +
    • + ); + })} +
    + )} +
    +

    Open clips

    {positions.length === 0 ? ( -

    Waiting on the server loop — Auto must stay on, kill off.

    +

    + {killed + ? "Engine paused. Start paper to send farm clips. Signals still scan." + : mode === "advisory" + ? "Signals — nothing is sent. Switch to Paper or Paper auto-send." + : "Waiting on the next scan."} +

    ) : ( -
    - - - - - - - - - - - - - - - - - {positions.map((p) => { - const px = ticks[p.symbol] ?? p.entryPrice; - const dir = p.side === "short" ? -1 : 1; - const pnl = (px - p.entryPrice) * p.qty * dir; - return ( - - - - - - - - - - - - - ); - })} - -
    SymbolSleeveSideQtyEntryOpened ISTLastStopMetaP&L
    - {p.symbol} - {(p.expiry || p.strike) && ( -
    - {[p.expiry, p.strike, p.right].filter(Boolean).join(" ")} -
    - )} -
    - {p.sleeve ?? "farm"} - - {p.side ?? "long"} - {p.qty}{p.entryPrice.toFixed(2)}{formatIstStamp(p.entryTs)}{px.toFixed(2)}{(p.stopPct * 100).toFixed(2)}%{(p.metaProb * 100).toFixed(0)}%= 0 ? "text-up" : "text-down"}>{inr(pnl)}
    -
    + <> +
      + {positions.map((p) => ( + + ))} +
    +
    + + + + + + + + + + + + + + + + {positions.map((p) => { + const px = ticks[p.symbol] ?? p.entryPrice; + const dir = p.side === "short" ? -1 : 1; + const pnl = (px - p.entryPrice) * p.qty * dir; + return ( + + + + + + + + + + + + + ); + })} + +
    SymbolSleeveSideQtyEntryHoldLastStopP&L +
    {p.symbol} + {p.sleeve ?? "farm"} + + {p.side ?? "long"} + {p.qty}{p.entryPrice.toFixed(2)}{holdLeft(p)}{px.toFixed(2)}{(p.stopPct * 100).toFixed(2)}%= 0 ? "text-up" : "text-down"}>{inr(pnl)} + +
    +
    + )}
    @@ -170,30 +341,40 @@ function AutoPage() { {f.side} {f.symbol} - {f.qty} @ {f.price.toFixed(2)} {formatIstStamp(f.ts)} + {f.qty} @ {f.price.toFixed(2)} - {(f.expiry || f.strike) && ( - {[f.expiry, f.strike, f.right].filter(Boolean).join(" ")} - )} - {f.quoteLabel && f.quoteLabel !== "live" && {f.quoteLabel}} + {formatIstStamp(f.ts)} + {quoteSource(f.quoteLabel)} {f.reason} ))} - {fills.length === 0 &&
  • Engine starting…
  • } + {fills.length === 0 &&
  • No fills. Start paper when you want clips sent.
  • }

    Last scan

    - {scan.length === 0 ? ( + {killed && mode !== "advisory" ? ( +

    + PAUSED{pausedAt ? ` at ${formatIstStamp(pausedAt)}` : ""}. Scan frozen. BUY is not live. +

    + ) : scan.length === 0 ? (

    First scan in a few seconds.

    ) : (
      {scan.slice(0, 24).map((r) => (
    • {r.symbol} - {r.action} - {(r.metaProb * 100).toFixed(0)}% meta + + {r.action} + + + {paper.data?.meta?.promoted ? `${(r.metaProb * 100).toFixed(0)}% meta` : "meta n/a"} + {r.reason}
    • ))} @@ -201,6 +382,58 @@ function AutoPage() { )}
    + {askReset && ( + setAskReset(false)} + onOk={() => { + setAskReset(false); + void resetDeskPaper(); + }} + /> + )}
    ); } + +function holdLeft(p: { sleeve?: string; entryTs: number }) { + if (p.sleeve === "pnl") return "Quality hold — no time-stop"; + const left = Math.max(0, FARM_PROFILE.TIME_STOP_SEC - (Date.now() - p.entryTs) / 1000); + return `${Math.round(left)}s to time-stop`; +} + +function ClipCard({ + p, + last, + operator, + needOp, +}: { + p: { symbol: string; entryTs: number; entryPrice: number; qty: number; side?: string; sleeve?: string; stopPct: number }; + last: number; + operator: boolean; + needOp: () => boolean; +}) { + const dir = p.side === "short" ? -1 : 1; + const pnl = (last - p.entryPrice) * p.qty * dir; + return ( +
  • +
    + {p.symbol} + {p.side ?? "long"} +
    +

    + {p.qty} @ {p.entryPrice.toFixed(2)} → {last.toFixed(2)} · stop {(p.stopPct * 100).toFixed(2)}% +

    +

    {holdLeft(p)}

    +
    + = 0 ? "text-up" : "text-down"}>{inr(pnl)} + +
    +
  • + ); +} diff --git a/src/routes/greeks.tsx b/src/routes/greeks.tsx index b841445..125e47a 100644 --- a/src/routes/greeks.tsx +++ b/src/routes/greeks.tsx @@ -2,17 +2,24 @@ import { createFileRoute } from "@tanstack/react-router"; import { useMemo, useState } from "react"; import { DeskShell } from "@/components/desk-shell"; import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; import { DEMO_NIFTY_LEGS, explainScalp, snapshotFromLegs, type OptionLeg } from "@/lib/meridian/greeks"; import { useDesk } from "@/lib/desk-store"; -import { inr } from "@/lib/utils"; +import { inr, formatIstStamp } from "@/lib/utils"; +import { queueDeskHedge, dismissDeskHedge } from "@/components/auto-engine"; +import { toast } from "sonner"; +import { useCurrentUserState } from "@/lib/auth/use-current-user"; export const Route = createFileRoute("/greeks")({ component: GreeksPage }); function GreeksPage() { const nifty = useDesk((s) => s.ticks.NIFTY ?? 24252); + const asOf = useDesk((s) => s.asOf); + const { user } = useCurrentUserState(); const [movePct, setMovePct] = useState(1); const [band, setBand] = useState(1); const [shortGamma, setShortGamma] = useState(false); + const [dismissed, setDismissed] = useState(false); const legs: OptionLeg[] = useMemo(() => { const mark = nifty; @@ -41,10 +48,11 @@ function GreeksPage() {

    Greeks

    -

    Gamma scalping

    +

    What-if calculator

    - Daily PnL is one-day theta. Gamma Scalping PnL is the textbook ½ Γ (ΔS)² term. Long gamma can help if you - keep leftover delta small. Short gamma does the opposite. Reviews only — not an order. + Demo / calculator — not the live paper book. Daily PnL is one-day theta. Gamma Scalping PnL is the textbook + ½ Γ (ΔS)² term. Marks are a snapshot + {asOf ? ` as of ${formatIstStamp(asOf)}` : ""}. Reviews only — not an order.

    @@ -56,10 +64,11 @@ function GreeksPage() { Short gamma {report.posture} gamma + Snapshot marks
    - + @@ -67,9 +76,7 @@ function GreeksPage() {
    - + {report.dailyPnlLine}

    {report.gammaScalpLine}

    {report.suggestion}

    - {report.needsRehedge && ( -

    - Suggested futures clip: {report.suggestedFuturesLots >= 0 ? "+" : ""} - {report.suggestedFuturesLots.toFixed(1)} lots (review only). -

    + {report.needsRehedge && !dismissed && ( +
    + + +
    )}
    diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 9160c1b..663f236 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -2,7 +2,8 @@ import { createFileRoute, Link } from "@tanstack/react-router"; import { useQuery } from "@tanstack/react-query"; import { DeskShell } from "@/components/desk-shell"; import { Badge } from "@/components/ui/badge"; -import { getMarket } from "@/lib/server/desk"; +import { PromotionStrip } from "@/components/promotion-strip"; +import { getMarket, getPaperBook } from "@/lib/server/desk"; import { useDesk } from "@/lib/desk-store"; import { inr, pct, formatPx, formatIst, formatIstStamp } from "@/lib/utils"; import { reviewHolding } from "@/lib/meridian/portfolio"; @@ -13,13 +14,14 @@ export const Route = createFileRoute("/")({ component: Command }); function Command() { const q = useQuery({ queryKey: ["market"], queryFn: () => getMarket(), refetchInterval: 12_000 }); + const paper = useQuery({ queryKey: ["paper"], queryFn: () => getPaperBook(), refetchInterval: 2500 }); const holdings = useDesk((s) => s.holdings); const positions = useDesk((s) => s.positions); const dailyPnl = useDesk((s) => s.dailyPnl); const ticks = useDesk((s) => s.ticks); const fills = useDesk((s) => s.fills); const regime = (q.data?.state.regime ?? "Calm") as MarketState["regime"]; - const reviews = holdings.map((h) => reviewHolding({ ...h, lastPrice: ticks[h.symbol] ?? h.lastPrice }, regime)); + const reviews = holdings.map((h) => reviewHolding({ ...h, lastPrice: ticks[h.symbol] ?? h.lastPrice }, regime, Boolean(paper.data?.meta?.promoted))); const bookValue = reviews.reduce((a, r) => a + r.value, 0); const bookPnl = reviews.reduce((a, r) => a + r.pnl, 0); const nifty = q.data?.state.nifty ?? ticks.NIFTY ?? 24252; @@ -33,8 +35,8 @@ function Command() {

    Command

    Multi-asset desk

    - Live Binance USDT + Yahoo NSE/FX/COMEX. Futures/options on Tape are derived. Auto paper. Not an order. - Live Kite stays disarmed. + Live Binance USDT + Yahoo NSE/FX/COMEX. Futures/options on Tape are derived. Paper only — Kite stays disarmed. + Not an order.

    @@ -52,6 +54,11 @@ function Command() {
    + + {paper.data?.lastDecision && ( +

    {paper.data.lastDecision}

    + )} +
    = 0} /> = 0} /> @@ -103,7 +110,7 @@ function Command() {
    -

    Imported book

    +

    Imported cash book

    {inr(bookValue)}

    = 0 ? "text-up" : "text-down"}`}>{inr(bookPnl)} vs cost

      @@ -124,7 +131,7 @@ function Command() {

      Latest paper fills

      {fills.length === 0 ? ( -

      No fills yet. Switch mode to Auto on the Auto page to let the engine work the watchlist.

      +

      No fills yet. Start paper when you want clips sent. Signals still scan.

      ) : (
      @@ -150,9 +157,7 @@ function Command() { - + ))} diff --git a/src/routes/login.tsx b/src/routes/login.tsx index 609e99d..7d3b97a 100644 --- a/src/routes/login.tsx +++ b/src/routes/login.tsx @@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { emailFromDeskId, TEST_DESK_ID } from "@/lib/desk-login"; import { seedTestDeskUser } from "@/lib/server/seed-test-user"; +import { englishAuthError } from "@/lib/ux-copy"; export const Route = createFileRoute("/login")({ loader: async () => { @@ -22,6 +23,7 @@ function Login() { const [password, setPassword] = useState(""); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); + const origin = typeof window !== "undefined" ? window.location.origin : ""; useEffect(() => { void seedTestDeskUser(); @@ -39,7 +41,7 @@ function Login() { password, }); if (err) { - setError(err.message ?? "Sign-in failed"); + setError(englishAuthError(err.message ?? "Sign-in failed", origin)); return; } const token = @@ -60,7 +62,7 @@ function Login() { void getBearerToken(); await navigate({ to: "/" }); } catch (ex) { - setError(ex instanceof Error ? ex.message : "Sign-in failed"); + setError(englishAuthError(ex instanceof Error ? ex.message : "Sign-in failed", origin)); } finally { setBusy(false); } @@ -73,7 +75,8 @@ function Login() {

      Meridian Final

      The desk that holds.

      - Test desk ID {TEST_DESK_ID}. Guest mode still runs the engines without signing in. + Test desk ID {TEST_DESK_ID}. Guest mode can watch the tape but cannot Pause, Reset, or change mode on the shared book. + Sign in to operate.

      {authEnabled ? ( @@ -99,6 +102,9 @@ function Login() { /> {error &&

      {error}

      } +

      + Host {origin || "…"}. localhost and 127.0.0.1 are both allowed on ports 3000 and 8080. +

      @@ -120,7 +126,7 @@ function Login() {

      Sign-in is disabled.

      )} - Back to the desk + Continue as guest diff --git a/src/routes/portfolio.tsx b/src/routes/portfolio.tsx index 12edc75..25d24fb 100644 --- a/src/routes/portfolio.tsx +++ b/src/routes/portfolio.tsx @@ -10,8 +10,9 @@ import { inr, pct } from "@/lib/utils"; import { saveHoldings } from "@/lib/server/desk"; import { useCurrentUser } from "@/lib/auth/use-current-user"; import { useQuery } from "@tanstack/react-query"; -import { getMarket } from "@/lib/server/desk"; +import { getMarket, getPaperBook } from "@/lib/server/desk"; import type { ActionLabel } from "@/lib/meridian/scoring"; +import { blockDeskSymbol } from "@/components/auto-engine"; export const Route = createFileRoute("/portfolio")({ component: PortfolioPage }); @@ -26,14 +27,17 @@ function PortfolioPage() { const holdings = useDesk((s) => s.holdings); const setHoldings = useDesk((s) => s.setHoldings); const ticks = useDesk((s) => s.ticks); + const positions = useDesk((s) => s.positions); const user = useCurrentUser(); const m = useQuery({ queryKey: ["market"], queryFn: () => getMarket() }); + const paper = useQuery({ queryKey: ["paper"], queryFn: () => getPaperBook(), refetchInterval: 2500 }); const regime = m.data?.state.regime ?? "Calm"; const [raw, setRaw] = useState(""); + const promoted = Boolean(paper.data?.meta?.promoted); const reviews = useMemo( - () => holdings.map((h) => reviewHolding({ ...h, lastPrice: ticks[h.symbol] ?? h.lastPrice }, regime)), - [holdings, ticks, regime], + () => holdings.map((h) => reviewHolding({ ...h, lastPrice: ticks[h.symbol] ?? h.lastPrice }, regime, promoted)), + [holdings, ticks, regime, promoted], ); const invested = reviews.reduce((a, r) => a + r.invested, 0); const value = reviews.reduce((a, r) => a + r.value, 0); @@ -56,21 +60,21 @@ function PortfolioPage() {

      Portfolio

      -

      Buy / Hold / Sell

      +

      Two ledgers

      - Upload a Zerodha-style CSV. Each line is scored with the V1 five-factor composite, V4 meta-probability, - and a predictability reading. Reviews always say they are not orders. + Imported cash book is your CSV. Paper clip book is the overnight farm. They are not the same. Factor Buy is not a + paper-model size while promotion is failed.

      - - - = 0} /> + + + = 0} />
      -

      Import

      +

      Import — cash book

      Headers such as Instrument, Qty., Avg. cost, LTP. Or paste below. Sample is preloaded as Core — Zerodha.

      @@ -124,8 +128,8 @@ NTPC,40,380,412`)
      - - + + @@ -143,13 +147,17 @@ NTPC,40,380,412`)
      {pct(r.pnlPct)}
      - + ))} @@ -157,6 +165,29 @@ NTPC,40,380,412`)
      {f.side} {f.qty} - {f.price.toFixed(2)} {formatIstStamp(f.ts)} - {f.price.toFixed(2)} {f.reason}
      P&L Score MetaPredictActionFactorAuto farm
      {r.score?.toFixed(2) ?? "—"}{(r.metaProb * 100).toFixed(0)}%{promoted && r.modelApplies ? r.metaLabel : "n/a"} -
      {r.predictability}
      -
      {r.strength}
      + {r.action} + {!promoted && (r.action === "Buy" || r.action === "Strong Buy") && ( +
      Factor only — not model-backed
      + )}
      - {r.action} +

      {reviews[0]?.note ?? "Not an order."}

      + +
      +

      Paper clip book

      +

      Open farm / PnL clips. Separate from the imported cash book.

      + {positions.length === 0 ? ( +

      No paper clips open.

      + ) : ( +
        + {positions.map((p) => ( +
      • + {p.symbol} + {p.sleeve ?? "farm"} + + {p.qty} @ {p.entryPrice.toFixed(2)} + + +
      • + ))} +
      + )} +
    ); diff --git a/src/routes/research.tsx b/src/routes/research.tsx index 217ca55..a9a2b21 100644 --- a/src/routes/research.tsx +++ b/src/routes/research.tsx @@ -8,8 +8,8 @@ import { Textarea } from "@/components/ui/input"; import { runResearch, type ResearchName } from "@/lib/server/desk"; import { useCurrentUserState } from "@/lib/auth/use-current-user"; import { UNIVERSE } from "@/lib/meridian/universe"; -import { compositeScore, mapAction } from "@/lib/meridian/scoring"; -import { factorParts } from "@/lib/meridian/universe"; +import { rankResearch } from "@/lib/meridian/research-rank"; +import { watchDeskSymbol, blockDeskSymbol } from "@/components/auto-engine"; export const Route = createFileRoute("/research")({ component: ResearchPage }); @@ -20,12 +20,20 @@ const EXAMPLES = [ "USDINR and G10 dollar pairs as a rupee hedge", ]; +type Run = { + query: string; + names: ResearchName[]; + source: string; + reason: string; + at: number; +}; + function ResearchPage() { const { user, isPending } = useCurrentUserState(); const [query, setQuery] = useState(EXAMPLES[0]); const [busy, setBusy] = useState(false); - const [names, setNames] = useState(null); - const [source, setSource] = useState(""); + const [history, setHistory] = useState([]); + const latest = history[0]; if (isPending) { return ( @@ -39,47 +47,30 @@ function ResearchPage() { setBusy(true); try { if (!user) { - const q = query.toLowerCase(); - const ranked = UNIVERSE.filter( - (u) => - u.themes.some((t) => q.includes(t.replace(/-/g, " "))) || - q.includes("data") || - q.includes("ai") || - q.includes("bank") || - q.includes("power") || - q.includes("crypto") || - q.includes("bitcoin") || - q.includes("gold") || - q.includes("forex") || - q.includes("dollar") || - q.includes("crude") || - q.includes("copper"), - ) - .slice(0, 6) - .map((u) => ({ - symbol: u.symbol, - name: u.name, - sector: u.sector, - score: u.quality, - why: u.thesis, - risk: "Guest shortlist from the in-desk universe. Sign in for Grok research. Not an order.", - sleeve: "Spot", - })); - setNames(ranked.length ? ranked : UNIVERSE.slice(0, 6).map((u) => ({ - symbol: u.symbol, - name: u.name, - sector: u.sector, - score: u.quality, - why: u.thesis, - risk: "Not an order.", - sleeve: "Spot", - }))); - setSource("desk"); + const ranked = rankResearch(query, UNIVERSE); + setHistory((h) => [ + { + query, + names: ranked.names, + source: "desk heuristic", + reason: ranked.reason, + at: Date.now(), + }, + ...h, + ].slice(0, 8)); return; } const res = await runResearch({ data: { query } }); - setNames(res.names); - setSource(res.source); + setHistory((h) => [ + { + query, + names: res.names, + source: res.source === "grok" ? "grok" : "desk heuristic", + reason: res.reason ?? "Ranked from the desk universe.", + at: Date.now(), + }, + ...h, + ].slice(0, 8)); } catch (e) { toast.error(e instanceof Error ? e.message : "Research failed"); } finally { @@ -89,14 +80,13 @@ function ResearchPage() { return ( - {!user ? null : signed in}

    Research

    Ask the desk

    - Natural-language query over the NSE universe Meridian actually models. Signed-in sessions call Grok. - Guests get the same ranked shortlist from the local book. Always a review. + Natural-language query over the universe Meridian actually models. Mismatched questions return zero names — never a + canned six-pack. Guests get a labelled heuristic. Signed-in sessions call Grok when a key is set.

    @@ -119,35 +109,62 @@ function ResearchPage() {
    - {names && ( -
    - {names.map((n) => { - const u = UNIVERSE.find((x) => x.symbol === n.symbol); - const action = u ? mapAction(compositeScore(factorParts(u), "Calm"), "Calm") : "—"; - return ( -
    -
    -
    -

    {n.symbol}

    -

    {n.name}

    + {latest && ( +
    +

    + Source: {latest.source}. {latest.reason} Not an order. +

    + {latest.names.length === 0 ? ( +

    {latest.reason}

    + ) : ( +
    + {latest.names.map((n) => ( +
    +
    +
    +

    {n.symbol}

    +

    {n.name}

    +
    + {n.sleeve} +
    +

    {n.sector}

    +

    {n.why}

    +

    {n.risk}

    +
    + score {n.score.toFixed(1)} + {latest.source} + +
    - {n.sleeve} -
    -

    {n.sector}

    -

    {n.why}

    -

    {n.risk}

    -
    - score {n.score.toFixed(1)} - {action} -
    -
    - ); - })} +

    Next: Watch to surface on Auto, or Ignore to block the farm.

    + + ))} +
    + )} +
    + )} + + {history.length > 1 && ( +
    +

    Earlier runs

    +
      + {history.slice(1).map((r) => ( +
    1. +

      {r.query}

      +

      + {r.source} · {r.names.length} names · {r.reason} +

      +
    2. + ))} +
    )} - {source &&

    Source: {source}. Not an order.

    } {!user && ( -

    Sign in to persist runs and route the query through Grok.

    +

    Guest heuristic — not Grok. Sign in to persist runs.

    )}