From 90e1829d394f3e15fbdf2526a56ef9ab6cab6ed8 Mon Sep 17 00:00:00 2001 From: Investor Flow Build Date: Tue, 18 Aug 2026 14:10:02 -0400 Subject: [PATCH] feat: Unraid deploy, dealer-flow heatmap, confluence zones, 13F capture Ship Node production images, Unraid compose, and Gitea CI/CD (test then push registry images; cron script if no runner). Rebuild dealer flow as a heatmap-first map with integrity gates and chart helpers. Add confluence zone rules, session clock, capture evidence, and tighter 13F/queue/options paths, plus the matching UI and tests. --- .env.example | 8 + .github/workflows/cd.yml | 54 + .github/workflows/ci.yml | 94 +- app/.dockerignore | 10 + app/Dockerfile | 43 +- app/next.config.ts | 1 + app/server/.dockerignore | 13 + app/server/Dockerfile | 38 +- app/server/scripts/sync-13f-deadline.ts | 127 ++ app/server/src/adapters/XCookieAdapter.ts | 16 + app/server/src/adapters/YFinanceAdapter.ts | 48 +- .../__tests__/YFinanceAdapter.test.ts | 21 +- app/server/src/alerts/AlertEngine.ts | 4 +- .../src/alerts/__tests__/AlertEngine.test.ts | 7 + .../alerts/producers/mirrorAlertProducers.ts | 6 +- .../__tests__/dealerExposureEngine.test.ts | 8 + .../__tests__/dealerMapIntegrity.test.ts | 35 + .../analysis/__tests__/sessionClock.test.ts | 24 + .../src/analysis/dealerExposureEngine.ts | 41 +- .../src/analysis/dealerFlowExplainNotes.ts | 2 +- app/server/src/analysis/dealerMapIntegrity.ts | 65 +- app/server/src/analysis/dealerMapService.ts | 5 +- app/server/src/analysis/sessionClock.ts | 25 + app/server/src/cache/CacheRepository.ts | 47 +- .../cache/__tests__/CacheRepository.test.ts | 36 + .../__tests__/candleProvider.test.ts | 18 +- .../__tests__/confluenceEngine.test.ts | 33 +- .../__tests__/confluenceRack.test.ts | 11 + .../__tests__/confluenceSeed.test.ts | 5 +- .../__tests__/confluenceZones.test.ts | 283 +++ app/server/src/confluence/candleProvider.ts | 23 + app/server/src/confluence/confluenceEngine.ts | 327 +++- .../src/confluence/confluenceLibrary.ts | 7 +- app/server/src/confluence/confluenceRack.ts | 5 +- app/server/src/confluence/confluenceSeed.ts | 23 +- app/server/src/confluence/confluenceZones.ts | 615 +++++++ .../src/db/__tests__/fund13fSync.test.ts | 84 +- .../src/db/__tests__/fundRepository.test.ts | 34 + app/server/src/db/__tests__/schema.test.ts | 2 +- .../src/db/alertSubscriptionRepository.ts | 2 +- app/server/src/db/client.ts | 39 +- app/server/src/db/confluenceRepository.ts | 102 +- app/server/src/db/fundRepository.ts | 293 +++- app/server/src/db/schema.sql | 18 + app/server/src/index.ts | 66 +- .../mirror/__tests__/captureParser.test.ts | 91 +- .../src/mirror/__tests__/mirrorEngine.test.ts | 18 + app/server/src/mirror/captureParser.ts | 132 +- app/server/src/mirror/fund13fFetcher.ts | 20 +- app/server/src/mirror/mirrorEngine.ts | 36 +- app/server/src/options/OptionsChainRouter.ts | 52 +- .../__tests__/OptionsChainRouter.test.ts | 31 + app/server/src/options/types.ts | 5 + app/server/src/queue/AdapterQueue.ts | 151 +- .../src/queue/__tests__/AdapterQueue.test.ts | 135 ++ .../queue/__tests__/sourceRatePolicy.test.ts | 13 + app/server/src/queue/sourceRatePolicy.ts | 29 +- .../__tests__/captureEvidence.test.ts | 55 + .../services/__tests__/captureIngest.test.ts | 115 +- .../services/__tests__/secCusipCache.test.ts | 4 + app/server/src/services/captureEvidence.ts | 90 + app/server/src/services/captureIngest.ts | 127 +- app/server/src/services/cusipRegistry.ts | 5 + app/server/src/services/reverse13fRefresh.ts | 2 +- app/server/src/services/secDataFetcher.ts | 6 +- app/server/src/trpc/router.ts | 219 ++- app/src/__tests__/dealerMapChrome.test.ts | 154 ++ app/src/__tests__/ema921Range.test.ts | 87 + app/src/__tests__/lastUpdated.test.ts | 25 + app/src/__tests__/priceChartGeometry.test.ts | 105 ++ app/src/__tests__/primary-rule-lint.test.ts | 5 + app/src/__tests__/relativeTime.test.ts | 45 + app/src/app/admin/queue/page.tsx | 128 +- app/src/app/admin/x-accounts/page.tsx | 16 +- app/src/app/alerts/page.tsx | 49 +- app/src/app/confluence/page.tsx | 10 +- app/src/app/funds/[id]/page.tsx | 246 ++- app/src/app/funds/page.tsx | 33 +- app/src/app/health/route.ts | 5 + app/src/app/more/page.tsx | 2 +- app/src/components/AlertEventCard.tsx | 145 ++ app/src/components/ChartLabPanel.tsx | 384 +++- app/src/components/ConfluenceChart.tsx | 193 +- app/src/components/ConfluencePanel.tsx | 254 ++- app/src/components/CorridorPanel.tsx | 29 +- app/src/components/FundHoldingsStrip.tsx | 8 +- app/src/components/NotificationBell.tsx | 51 +- app/src/components/OverviewPanel.tsx | 22 +- app/src/components/SocialFeedPanel.tsx | 23 +- app/src/components/SymbolHeader.tsx | 20 + app/src/components/chart/PriceCandleLayer.tsx | 188 ++ .../components/dealer-flow/DealerFlowView.tsx | 1559 ++++------------- .../components/dealer-flow/DealerHeatmap.tsx | 408 +++++ .../components/dealer-flow/StudyDeskPanel.tsx | 50 +- app/src/components/shared/LastUpdated.tsx | 29 + app/src/lib/dealerMapChrome.ts | 206 +++ app/src/lib/ema921Range.ts | 170 ++ app/src/lib/lastUpdatedCopy.ts | 37 + app/src/lib/priceChartGeometry.ts | 171 ++ app/src/lib/relativeTime.ts | 38 + app/src/lib/strings.ts | 36 +- app/src/lib/trpc.ts | 100 +- deploy/unraid/.env.example | 34 + deploy/unraid/act-runner-compose.yml | 27 + deploy/unraid/compose.pull.yml | 55 + deploy/unraid/user-scripts/sync-and-up.sh | 60 + docker-compose.yml | 80 +- docs/DEPLOY_UNRAID.md | 118 ++ docs/FUNCTIONAL_DESIGN.md | 6 +- docs/TECH_DESIGN.md | 29 +- docs/adr/0012-confluence-signal-engine.md | 9 +- flow/gex.jpeg | Bin 0 -> 282961 bytes flow/vex.jpeg | Bin 0 -> 271226 bytes 113 files changed, 7606 insertions(+), 1947 deletions(-) create mode 100644 .github/workflows/cd.yml create mode 100644 app/.dockerignore create mode 100644 app/server/.dockerignore create mode 100644 app/server/scripts/sync-13f-deadline.ts create mode 100644 app/server/src/analysis/__tests__/sessionClock.test.ts create mode 100644 app/server/src/analysis/sessionClock.ts create mode 100644 app/server/src/confluence/__tests__/confluenceZones.test.ts create mode 100644 app/server/src/confluence/confluenceZones.ts create mode 100644 app/server/src/services/__tests__/captureEvidence.test.ts create mode 100644 app/server/src/services/captureEvidence.ts create mode 100644 app/src/__tests__/dealerMapChrome.test.ts create mode 100644 app/src/__tests__/ema921Range.test.ts create mode 100644 app/src/__tests__/lastUpdated.test.ts create mode 100644 app/src/__tests__/priceChartGeometry.test.ts create mode 100644 app/src/__tests__/relativeTime.test.ts create mode 100644 app/src/app/health/route.ts create mode 100644 app/src/components/AlertEventCard.tsx create mode 100644 app/src/components/chart/PriceCandleLayer.tsx create mode 100644 app/src/components/dealer-flow/DealerHeatmap.tsx create mode 100644 app/src/components/shared/LastUpdated.tsx create mode 100644 app/src/lib/dealerMapChrome.ts create mode 100644 app/src/lib/ema921Range.ts create mode 100644 app/src/lib/lastUpdatedCopy.ts create mode 100644 app/src/lib/priceChartGeometry.ts create mode 100644 app/src/lib/relativeTime.ts create mode 100644 deploy/unraid/.env.example create mode 100644 deploy/unraid/act-runner-compose.yml create mode 100644 deploy/unraid/compose.pull.yml create mode 100755 deploy/unraid/user-scripts/sync-and-up.sh create mode 100644 docs/DEPLOY_UNRAID.md create mode 100644 flow/gex.jpeg create mode 100644 flow/vex.jpeg diff --git a/.env.example b/.env.example index d49c9b1..bc339b0 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,17 @@ # Investor Flow — Environment Variables # Copy this file to .env and fill in the values. +# Unraid production: start from deploy/unraid/.env.example instead. # ─── Security ─────────────────────────────────────────────────────────────── # Session secret for JWT signing (generate with: openssl rand -hex 32) IFLOW_SESSION_SECRET=dev-secret-change-me +# AES-256-GCM key for x_credentials (64 hex chars). Required in production if +# you migrate an existing DB. openssl rand -hex 32 +IFLOW_CRYPTO_KEY= + +# SQLite file inside the backend container / local checkout +IFLOW_DB_PATH= +IFLOW_DATA_DIR=./app/server/data # ─── SEC EDGAR ────────────────────────────────────────────────────────────── # Operator email for EDGAR user-agent (required by SEC fair-use policy) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..16445d9 --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,54 @@ +# Manual image rebuild. The automatic path is the `images` job in ci.yml +# (runs after tests on push to main). Keep this for "rebuild without a new commit". + +name: CD + +on: + workflow_dispatch: + +jobs: + images: + name: Build and push + runs-on: ubuntu-latest + env: + REGISTRY: ${{ vars.REGISTRY || '10.37.0.86:3003' }} + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + config-inline: | + [registry."10.37.0.86:3003"] + http = true + insecure = true + [registry."unraid.local:3003"] + http = true + insecure = true + + - name: Log in to Gitea registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.REGISTRY_TOKEN || github.token }} + + - name: Backend image + uses: docker/build-push-action@v6 + with: + context: ./app/server + file: ./app/server/Dockerfile + push: true + tags: | + ${{ env.REGISTRY }}/transnet/investor-flow-backend:${{ github.sha }} + ${{ env.REGISTRY }}/transnet/investor-flow-backend:latest + + - name: Frontend image + uses: docker/build-push-action@v6 + with: + context: ./app + file: ./app/Dockerfile + push: true + tags: | + ${{ env.REGISTRY }}/transnet/investor-flow-frontend:${{ github.sha }} + ${{ env.REGISTRY }}/transnet/investor-flow-frontend:latest diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1b0292..17be32c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,11 @@ -# Investor Flow — CI Pipeline -# Runs on every PR and push to main. Lint, type-check, and test both -# frontend and backend. All checks must pass before merge. +# Gitea Actions (GitHub-compatible). Tests on every PR / push. +# On push to main, also builds and pushes images to the Gitea registry. +# +# Requires a registered act_runner labeled ubuntu-latest. Without a runner +# Gitea marks the run cancelled. See deploy/unraid/act-runner-compose.yml. +# +# Repo secret REGISTRY_TOKEN: Gitea token with write:package + write:repository. +# Optional var REGISTRY: default 10.37.0.86:3003 (HTTP; mark insecure in Docker). name: CI @@ -9,10 +14,11 @@ on: branches: [main] pull_request: branches: [main] + workflow_dispatch: jobs: test: - name: Test & Type-Check + name: Test runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -21,17 +27,83 @@ jobs: uses: actions/setup-node@v4 with: node-version: 22 + cache: npm + cache-dependency-path: | + app/package-lock.json + app/server/package-lock.json - - name: Install dependencies + - name: Install frontend working-directory: app - run: npm install + run: npm ci - - name: Type-check (tsc --noEmit) - working-directory: app - run: npx tsc --noEmit || true # non-blocking for now + - name: Install backend + working-directory: app/server + run: npm ci - - name: Run tests + - name: Frontend tests working-directory: app - run: node --test --experimental-strip-types "src/**/*.test.ts" "server/src/**/*.test.ts" + run: node --test --experimental-strip-types "src/**/*.test.ts" env: NODE_ENV: development + + - name: Backend tests + working-directory: app/server + run: npm test + env: + NODE_ENV: development + + - name: Backend typecheck + working-directory: app/server + run: npm run typecheck + + images: + name: Build and push + needs: test + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + env: + REGISTRY: ${{ vars.REGISTRY || '10.37.0.86:3003' }} + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + config-inline: | + [registry."10.37.0.86:3003"] + http = true + insecure = true + [registry."unraid.local:3003"] + http = true + insecure = true + + - name: Log in to Gitea registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.REGISTRY_TOKEN || github.token }} + + - name: Backend image + uses: docker/build-push-action@v6 + with: + context: ./app/server + file: ./app/server/Dockerfile + push: true + tags: | + ${{ env.REGISTRY }}/transnet/investor-flow-backend:${{ github.sha }} + ${{ env.REGISTRY }}/transnet/investor-flow-backend:latest + cache-from: type=registry,ref=${{ env.REGISTRY }}/transnet/investor-flow-backend:buildcache + cache-to: type=registry,ref=${{ env.REGISTRY }}/transnet/investor-flow-backend:buildcache,mode=max + + - name: Frontend image + uses: docker/build-push-action@v6 + with: + context: ./app + file: ./app/Dockerfile + push: true + tags: | + ${{ env.REGISTRY }}/transnet/investor-flow-frontend:${{ github.sha }} + ${{ env.REGISTRY }}/transnet/investor-flow-frontend:latest + cache-from: type=registry,ref=${{ env.REGISTRY }}/transnet/investor-flow-frontend:buildcache + cache-to: type=registry,ref=${{ env.REGISTRY }}/transnet/investor-flow-frontend:buildcache,mode=max diff --git a/app/.dockerignore b/app/.dockerignore new file mode 100644 index 0000000..51d56f0 --- /dev/null +++ b/app/.dockerignore @@ -0,0 +1,10 @@ +node_modules +.next +data +server +**/*.test.ts +**/*.md +Dockerfile +.dockerignore +.git +coverage diff --git a/app/Dockerfile b/app/Dockerfile index 2e8cf98..3a714d7 100644 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -1,20 +1,35 @@ -# Investor Flow — Frontend Dockerfile (Next.js) -FROM node:22-alpine AS frontend - +# Investor Flow frontend — Next.js standalone. +# Build context: app/ +FROM node:22-bookworm-slim AS deps WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci -# Install dependencies -COPY package.json package-lock.json* ./ -RUN npm install --frozen-lockfile || npm install - -# Copy app source +FROM node:22-bookworm-slim AS builder +WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 +COPY --from=deps /app/node_modules ./node_modules COPY . . - -# Build the Next.js app RUN npm run build -# Expose frontend port +FROM node:22-bookworm-slim AS runner +WORKDIR /app +ENV NODE_ENV=production \ + NEXT_TELEMETRY_DISABLED=1 \ + HOSTNAME=0.0.0.0 \ + PORT=3000 \ + IFLOW_BACKEND_URL=http://backend:3001 +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates tini \ + && rm -rf /var/lib/apt/lists/* \ + && addgroup --system --gid 1001 nodejs \ + && adduser --system --uid 1001 --ingroup nodejs nextjs +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +USER nextjs EXPOSE 3000 - -# Start the Next.js server -CMD ["npm", "run", "start"] +HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" +ENTRYPOINT ["tini", "--"] +CMD ["node", "server.js"] diff --git a/app/next.config.ts b/app/next.config.ts index 5e70dda..89f39f2 100644 --- a/app/next.config.ts +++ b/app/next.config.ts @@ -4,6 +4,7 @@ import type { NextConfig } from "next"; // Do NOT use rewrites for /api - Next rewrite proxy hard-times-out ~30s and returns // plain "Internal Server Error" for slow LLM paths (dealerMap.explain, userLlm.test). const nextConfig: NextConfig = { + output: "standalone", typescript: { // The server/ directory uses .ts import extensions (Node native TS) and has its // own type-check via `npm run typecheck` in server/. Skip Next.js type checking diff --git a/app/server/.dockerignore b/app/server/.dockerignore new file mode 100644 index 0000000..f39ad61 --- /dev/null +++ b/app/server/.dockerignore @@ -0,0 +1,13 @@ +node_modules +data +*.db +*.db-shm +*.db-wal +**/*.test.ts +scripts +queue-cifr.ts +review.md +Dockerfile +.dockerignore +.git +*.md diff --git a/app/server/Dockerfile b/app/server/Dockerfile index 1d6631d..5b4f01b 100644 --- a/app/server/Dockerfile +++ b/app/server/Dockerfile @@ -1,17 +1,27 @@ -# Investor Flow — Backend Dockerfile (Bun) -FROM oven/bun:1-alpine AS backend - +# Investor Flow backend — Node 22 + native type-stripping + node:sqlite. +# Build context: app/server +FROM node:22-bookworm-slim AS deps WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev -# Install dependencies -COPY server/package.json server/bun.lock* ./ -RUN bun install --frozen-lockfile || bun install - -# Copy server source -COPY server/ ./ - -# Expose backend port +FROM node:22-bookworm-slim AS runner +WORKDIR /app +ENV NODE_ENV=production \ + HOST=0.0.0.0 \ + PORT=3001 \ + IFLOW_DB_PATH=/app/data/investor-flow.db +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates tini \ + && rm -rf /var/lib/apt/lists/* \ + && mkdir -p /app/data +COPY --from=deps /app/node_modules ./node_modules +COPY package.json ./ +COPY tsconfig.json ./ +COPY src ./src EXPOSE 3001 - -# Start the server -CMD ["bun", "run", "dev"] +VOLUME ["/app/data"] +HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:3001/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" +ENTRYPOINT ["tini", "--"] +CMD ["node", "--experimental-strip-types", "src/index.ts"] diff --git a/app/server/scripts/sync-13f-deadline.ts b/app/server/scripts/sync-13f-deadline.ts new file mode 100644 index 0000000..fa2e87f --- /dev/null +++ b/app/server/scripts/sync-13f-deadline.ts @@ -0,0 +1,127 @@ +#!/usr/bin/env -S node --experimental-strip-types +/** + * Q2 13F deadline helper. + * + * node --experimental-strip-types scripts/sync-13f-deadline.ts + * node --experimental-strip-types scripts/sync-13f-deadline.ts --watch + * + * Syncs every enabled tracked fund (Alpine Fox first), then requeues + * institution-filing refreshes for open portfolio symbols. + * --watch keeps polling tracked-fund 13Fs until 2026-Q2 lands or 23:59 ET. + */ +import { createDb } from '../src/db/client.ts'; +import { queueSecFetch } from '../src/admin/admin.ts'; +import { fetchFund13F } from '../src/mirror/fund13fFetcher.ts'; +import { seedCuratedCusips } from '../src/services/cusipRegistry.ts'; + +const TARGET_QUARTER = '2026-Q2'; +const POLL_MS = 15 * 60_000; + +function hasQuarter(db: ReturnType, cik: string, quarter: string): boolean { + const row = db + .prepare( + `SELECT COUNT(*) AS n FROM institution_filings + WHERE filer_cik IN (?, ?) AND form='13F-HR' AND reported_quarter=?`, + ) + .get(cik, cik.replace(/^0+/, ''), quarter) as { n: number }; + return row.n > 0; +} + +async function syncFunds(db: ReturnType): Promise<{ + funds: Array<{ id: string; name: string; cik: string; result: unknown; hasTarget: boolean }>; +}> { + const funds = db + .prepare(`SELECT id, fund_name, ci_key FROM tracked_funds WHERE enabled=1 ORDER BY fund_name`) + .all() as Array<{ id: string; fund_name: string; ci_key: string }>; + const out = []; + for (const f of funds) { + if (!/^\d+$/.test(f.ci_key.replace(/^0+/, '')) && !/^\d{10}$/.test(f.ci_key)) { + console.log(`[skip] ${f.fund_name} has no SEC CIK (${f.ci_key})`); + continue; + } + console.log(`[fund] syncing ${f.fund_name} (${f.ci_key})…`); + try { + const result = await fetchFund13F(db, f.id); + const hasTarget = hasQuarter(db, f.ci_key, TARGET_QUARTER); + console.log( + `[fund] ${f.fund_name}: filings=${result.fetched_filings} holdings=${result.holdings_seen} upserted=${result.symbols_upserted} records=${result.records_inserted} ${TARGET_QUARTER}=${hasTarget ? 'YES' : 'not yet'}`, + ); + if (result.unresolved.length) { + const uniq = [...new Map(result.unresolved.map((u) => [u.cusip, u])).values()]; + console.log(`[fund] unresolved CUSIPs (${uniq.length}): ${uniq.slice(0, 12).map((u) => `${u.cusip}/${u.issuerName}`).join('; ')}`); + } + out.push({ id: f.id, name: f.fund_name, cik: f.ci_key, result, hasTarget }); + } catch (e) { + console.error(`[fund] ${f.fund_name} FAILED:`, e instanceof Error ? e.message : e); + out.push({ id: f.id, name: f.fund_name, cik: f.ci_key, result: { error: String(e) }, hasTarget: false }); + } + } + return { funds: out }; +} + +function requeuePortfolio(db: ReturnType): string[] { + const rows = db + .prepare(`SELECT DISTINCT symbol FROM portfolio_holdings WHERE status='open' ORDER BY symbol`) + .all() as Array<{ symbol: string }>; + const symbols = rows.map((r) => r.symbol.toUpperCase()); + db.prepare("DELETE FROM queue_state WHERE key LIKE 'source_cooldown:%' AND key LIKE '%sec%'").run(); + for (const sym of symbols) { + queueSecFetch(db, sym); + } + console.log(`[queue] pending sec-fetch for ${symbols.join(', ')}`); + return symbols; +} + +function etDeadlineMs(now = Date.now()): number { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: 'America/New_York', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(new Date(now)); + const get = (t: string) => parts.find((p) => p.type === t)?.value ?? ''; + const y = get('year'); + const m = get('month'); + const d = get('day'); + return Date.parse(`${y}-${m}-${d}T23:59:00-04:00`); +} + +async function main() { + const watch = process.argv.includes('--watch'); + const db = createDb(); + seedCuratedCusips(db); + + const first = await syncFunds(db); + requeuePortfolio(db); + + const alpine = first.funds.find((f) => /alpine/i.test(f.name)); + if (alpine?.hasTarget) { + console.log(`DONE Alpine Fox ${TARGET_QUARTER} is in institution_filings`); + return; + } + + if (!watch) { + console.log(`${TARGET_QUARTER} not filed yet for Alpine Fox. Re-run with --watch to poll.`); + return; + } + + const until = etDeadlineMs(); + console.log(`[watch] polling every ${POLL_MS / 60000}m until ${new Date(until).toISOString()}`); + while (Date.now() < until) { + await new Promise((r) => setTimeout(r, POLL_MS)); + const next = await syncFunds(db); + const hit = next.funds.find((f) => /alpine/i.test(f.name) && f.hasTarget); + if (hit) { + requeuePortfolio(db); + console.log(`DONE Alpine Fox ${TARGET_QUARTER} ingested`); + return; + } + console.log(`[watch] still no ${TARGET_QUARTER} for Alpine Fox at ${new Date().toISOString()}`); + } + console.log(`FAILED Alpine Fox ${TARGET_QUARTER} not on EDGAR before deadline`); +} + +main().catch((e) => { + console.error(e instanceof Error ? e.message : e); + process.exit(1); +}); diff --git a/app/server/src/adapters/XCookieAdapter.ts b/app/server/src/adapters/XCookieAdapter.ts index da15795..39afbf7 100644 --- a/app/server/src/adapters/XCookieAdapter.ts +++ b/app/server/src/adapters/XCookieAdapter.ts @@ -214,6 +214,22 @@ export class XCookieAdapter implements SourceFetch { }; } + /** Authenticated single-tweet read. Paywalled posts resolve; deleted do not. */ + async readTweetStatus(postId: string): Promise<'ok' | 'deleted' | 'unknown'> { + try { + const stdout = await this.runBird(['read', postId, '--json']); + JSON.parse(stdout); + return 'ok'; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + const { isTweetGoneMessage } = await import('../services/captureEvidence.ts'); + if (isTweetGoneMessage(msg)) { + return 'deleted'; + } + return 'unknown'; + } + } + async fetchOne(key: string, opts?: FetchOpts): Promise { const parts = key.split(':'); if (parts.length < 2) throw new Error(`XCookieAdapter: invalid cache key "${key}"`); diff --git a/app/server/src/adapters/YFinanceAdapter.ts b/app/server/src/adapters/YFinanceAdapter.ts index 320d1c2..780535c 100644 --- a/app/server/src/adapters/YFinanceAdapter.ts +++ b/app/server/src/adapters/YFinanceAdapter.ts @@ -12,11 +12,39 @@ import { withVendorGate } from '../services/vendorGate.ts'; // Polite UA including operator contact (yfinance community norm; EDGAR requires similar). const OPERATOR_EMAIL = process.env.YF_OPERATOR_EMAIL ?? 'operator@example.com'; -/** Full history window for cold candle fills (~10y). */ +/** Full history window for cold daily/weekly/monthly fills (~10y). */ const FULL_HISTORY_DAYS = 3650; -/** When we already have bars, only re-pull recent history + a small overlap. */ +/** When we already have daily bars, only re-pull recent history + a small overlap. */ const INCREMENTAL_LOOKBACK_DAYS = 14; +/** Yahoo will not serve a longer 1m/5m window than this. */ +const INTRADAY_MAX_DAYS: Record = { "1m": 7, "5m": 60 }; +const INTRADAY_WARM_DAYS: Record = { "1m": 1, "5m": 3 }; + +/** + * First timestamp Yahoo should be asked for. Minute TFs are capped; + * daily stays on the existing 10y / 14d incremental rule. + */ +export function candleLookbackStart( + timeframe: string, + lastTs: string | null, + now = Date.now(), +): Date { + const maxDays = INTRADAY_MAX_DAYS[timeframe]; + if (maxDays != null) { + const floor = now - maxDays * 86_400_000; + if (!lastTs) return new Date(floor); + const last = Date.parse(lastTs); + if (!Number.isFinite(last)) return new Date(floor); + const warmDays = INTRADAY_WARM_DAYS[timeframe] ?? 1; + return new Date(Math.max(floor, last - warmDays * 86_400_000)); + } + if (!lastTs) return new Date(now - FULL_HISTORY_DAYS * 86_400_000); + const last = Date.parse(lastTs); + if (!Number.isFinite(last)) return new Date(now - FULL_HISTORY_DAYS * 86_400_000); + return new Date(last - INCREMENTAL_LOOKBACK_DAYS * 86_400_000); +} + export interface YFinanceAdapterOptions { /** Optional DB for incremental candle period1 (last bar ts). */ db?: DatabaseSync; @@ -153,20 +181,10 @@ export class YFinanceAdapter implements SourceFetch { } } - /** Cold = full history; warm = last bar − lookback. */ + /** Cold = full history (or Yahoo cap); warm = last bar − overlap. */ private candlePeriod1(symbol: string, timeframe: string): Date { - if (!this._db) { - return new Date(Date.now() - FULL_HISTORY_DAYS * 86_400_000); - } - const last = lastCandleTs(this._db, symbol, timeframe); - if (!last) { - return new Date(Date.now() - FULL_HISTORY_DAYS * 86_400_000); - } - const t = Date.parse(last); - if (!Number.isFinite(t)) { - return new Date(Date.now() - FULL_HISTORY_DAYS * 86_400_000); - } - return new Date(t - INCREMENTAL_LOOKBACK_DAYS * 86_400_000); + const last = this._db ? lastCandleTs(this._db, symbol, timeframe) : null; + return candleLookbackStart(timeframe, last); } } diff --git a/app/server/src/adapters/__tests__/YFinanceAdapter.test.ts b/app/server/src/adapters/__tests__/YFinanceAdapter.test.ts index 6a19b31..ab1316d 100644 --- a/app/server/src/adapters/__tests__/YFinanceAdapter.test.ts +++ b/app/server/src/adapters/__tests__/YFinanceAdapter.test.ts @@ -3,7 +3,7 @@ import { strict as assert } from 'node:assert'; import { readFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { parseQuote, parseCandles, parseSymbol, inferTickerKind } from '../YFinanceAdapter.ts'; +import { parseQuote, parseCandles, parseSymbol, inferTickerKind, candleLookbackStart } from '../YFinanceAdapter.ts'; import { FakeSourceAdapter } from '../SourceAdapter.ts'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -99,6 +99,25 @@ test('parseCandles returns [] when quotes is absent/empty', () => { assert.deepEqual(parseCandles({ quotes: [null, { close: null }] }), []); }); +test('candleLookbackStart caps 1m to 7d and 5m to 60d', () => { + const now = Date.parse('2026-08-16T16:00:00Z'); + const m1 = candleLookbackStart('1m', null, now); + const m5 = candleLookbackStart('5m', null, now); + const d1 = candleLookbackStart('1d', null, now); + assert.ok(now - m1.getTime() <= 7 * 86_400_000 + 1000); + assert.ok(now - m1.getTime() >= 7 * 86_400_000 - 1000); + assert.ok(now - m5.getTime() <= 60 * 86_400_000 + 1000); + assert.ok(now - m5.getTime() >= 60 * 86_400_000 - 1000); + assert.ok(now - d1.getTime() > 1000 * 86_400_000); +}); + +test('candleLookbackStart warm 1m does not reach past the 7d floor', () => { + const now = Date.parse('2026-08-16T16:00:00Z'); + const last = '2026-07-01T16:00:00Z'; + const start = candleLookbackStart('1m', last, now); + assert.ok(start.getTime() >= now - 7 * 86_400_000 - 1000); +}); + test('parseSymbol extracts sector/industry from summaryProfile', () => { const raw = readFixture('nvda.summary.json'); const s = parseSymbol(raw, 'NVDA'); diff --git a/app/server/src/alerts/AlertEngine.ts b/app/server/src/alerts/AlertEngine.ts index 85f728c..4a4826d 100644 --- a/app/server/src/alerts/AlertEngine.ts +++ b/app/server/src/alerts/AlertEngine.ts @@ -126,7 +126,7 @@ export function alertTitle(type: AlertType, symbol?: string): string { case 'fund_13f': return `New 13F from tracked fund`; case 'mirror_diff': - return `Mirror target changed${symbolTag(symbol)}`; + return `Tracked fund book changed${symbolTag(symbol)}`; case 'vix_level': return `Volatility index level changed`; case 'confluence_change': @@ -164,7 +164,7 @@ export function alertDescription(type: AlertType, details?: string, symbol?: str case 'fund_13f': return `A tracked fund filed a new 13F. This is a disclosure, not advice.`; case 'mirror_diff': - return `The mirror target changed${symbolTag(symbol)}. Showing the arithmetic delta; it is not advice.`; + return `A tracked fund's book changed${symbolTag(symbol)}. Showing the arithmetic delta; it is not advice.`; case 'vix_level': return `The VIX, a market-wide measure of expected near-term volatility, has moved into a new level that historically mattered to market participants.`; case 'confluence_change': diff --git a/app/server/src/alerts/__tests__/AlertEngine.test.ts b/app/server/src/alerts/__tests__/AlertEngine.test.ts index 625323f..d71b47b 100644 --- a/app/server/src/alerts/__tests__/AlertEngine.test.ts +++ b/app/server/src/alerts/__tests__/AlertEngine.test.ts @@ -37,6 +37,7 @@ test('alertTitle returns expected title for each alert type', () => { assert.equal(alertTitle('regime_shift'), 'Market regime changed'); assert.equal(alertTitle('drawdown_halt'), 'Drawdown tolerance breached'); assert.equal(alertTitle('informed_buy', 'AAPL'), 'Insider bought for AAPL'); + assert.equal(alertTitle('mirror_diff', 'SNOW'), 'Tracked fund book changed for SNOW'); }); test('alertDescription never contains trade verbs', () => { @@ -56,6 +57,12 @@ test('alertDescription never contains trade verbs', () => { } }); +test('alertDescription for mirror_diff names the tracked fund book', () => { + const desc = alertDescription('mirror_diff', undefined, 'SHAZ'); + assert.match(desc, /tracked fund/i); + assert.ok(!/mirror target/i.test(desc)); +}); + test('alertDescription includes ADR-0007 compliant language', () => { const desc = alertDescription('drawdown_halt'); assert.ok(desc.includes('circuit breaker'), 'Should mention circuit breaker'); diff --git a/app/server/src/alerts/producers/mirrorAlertProducers.ts b/app/server/src/alerts/producers/mirrorAlertProducers.ts index 0f2e976..76cc656 100644 --- a/app/server/src/alerts/producers/mirrorAlertProducers.ts +++ b/app/server/src/alerts/producers/mirrorAlertProducers.ts @@ -6,7 +6,7 @@ // 2. fund_13f — a new 13F quarter landed from a tracked fund. // 3. mirror_diff — the live book behind the user's mirror changed materially. // -// ADR-0010: every payload string is MECHANICAL ("to match your mirror target, the +// ADR-0010: every payload string is MECHANICAL ("to match the tracked fund book, the // delta is ..."). No advice-shaped sentence. Lint-enforced in sub-task 9. import type { DatabaseSync } from 'node:sqlite'; @@ -136,8 +136,8 @@ export const mirrorDiffProducer: AlertProducer = { const dedup = `mirror_diff:${userId}:${fund.id}:${pos.symbol}:${sig}`; if (isDuplicate(db, buildDedupKey('mirror_diff', pos.symbol, dedup))) continue; const delta = pos.shares != null - ? `To match your mirror target for ${pos.symbol}, the delta is ${rnd(pos.shares).toLocaleString('en-US')} shares as disclosed ${pos.as_of}.` - : `The ${pos.symbol} position in the mirror target updated as of ${pos.as_of}.`; + ? `To match the tracked fund book for ${pos.symbol}, the delta is ${rnd(pos.shares).toLocaleString('en-US')} shares as disclosed ${pos.as_of}.` + : `The ${pos.symbol} position in the tracked fund book updated as of ${pos.as_of}.`; const alert = createAlert( crypto.randomUUID(), userId, 'mirror_diff', pos.symbol, `${fund.fund_name} updated its ${pos.symbol} book (${pos.as_of}). ${delta}`, diff --git a/app/server/src/analysis/__tests__/dealerExposureEngine.test.ts b/app/server/src/analysis/__tests__/dealerExposureEngine.test.ts index 0893728..72cff24 100644 --- a/app/server/src/analysis/__tests__/dealerExposureEngine.test.ts +++ b/app/server/src/analysis/__tests__/dealerExposureEngine.test.ts @@ -3,6 +3,7 @@ import { strict as assert } from 'node:assert'; import { buildDealerMap, contractGex, + contractVex, isDegradedDealerMap, isHealthyDealerMap, isSaneIv, @@ -50,6 +51,13 @@ test('contractGex: call positive, put negative', () => { assert.equal(call, -put); }); +test('contractVex uses the same S² notional as GEX', () => { + const gex = contractGex(0.02, 1000, 44, 'call'); + const vex = contractVex(0.02, 1000, 44, 'call'); + assert.equal(vex, gex); + assert.equal(contractVex(0.02, 1000, 44, 'put'), -vex); +}); + test('buildDealerMap: expiries sorted nearest-left and capped', () => { const rows: NormalizedOptionRow[] = []; for (const exp of ['2026-08-08', '2026-08-15', '2026-08-22', '2026-09-19', '2026-10-17', '2026-11-21', '2026-12-19', '2027-01-16']) { diff --git a/app/server/src/analysis/__tests__/dealerMapIntegrity.test.ts b/app/server/src/analysis/__tests__/dealerMapIntegrity.test.ts index 1e8bc14..69919d4 100644 --- a/app/server/src/analysis/__tests__/dealerMapIntegrity.test.ts +++ b/app/server/src/analysis/__tests__/dealerMapIntegrity.test.ts @@ -71,6 +71,41 @@ test('auditDealerMap: complete fat surface + map', () => { assert.match(integritySummary(r), /complete/i); }); +test('auditDealerSurface: Friday chain on Sunday is not stale', () => { + const s = fatSurface(); + s.asOf = '2026-08-16T17:00:00.000Z'; + s.quoteAsOf = '2026-08-16T17:00:00.000Z'; + s.chainAsOf = '2026-08-14T21:30:00.000Z'; + const r = auditDealerSurface(s); + const age = r.checks.find((c) => c.id === 'chain_age'); + assert.ok(age); + assert.equal(age!.ok, true); + assert.ok(!r.hardFailed.includes('chain_age')); +}); + +test('auditDealerSurface: 3-session-old book is incomplete', () => { + const s = fatSurface(); + s.asOf = '2026-08-16T17:00:00.000Z'; + s.quoteAsOf = '2026-08-16T17:00:00.000Z'; + s.chainAsOf = '2026-08-12T03:46:00.000Z'; + const r = auditDealerSurface(s); + assert.equal(r.status, 'incomplete'); + assert.ok(r.hardFailed.includes('chain_age'), JSON.stringify(r.hardFailed)); +}); + +test('auditDealerSurface: spot newer than book is soft degraded', () => { + const s = fatSurface(); + s.asOf = '2026-08-14T20:00:00.000Z'; + s.quoteAsOf = '2026-08-14T20:00:00.000Z'; + s.chainAsOf = '2026-08-14T10:00:00.000Z'; + const r = auditDealerSurface(s); + assert.notEqual(r.status, 'incomplete'); + const mix = r.checks.find((c) => c.id === 'spot_vs_chain'); + assert.ok(mix); + assert.equal(mix!.ok, false); + assert.equal(r.status, 'degraded'); +}); + test('auditDealerSurface: delay alone does not hard-fail', () => { const s = fatSurface(); s.delaySeconds = 900; diff --git a/app/server/src/analysis/__tests__/sessionClock.test.ts b/app/server/src/analysis/__tests__/sessionClock.test.ts new file mode 100644 index 0000000..109c371 --- /dev/null +++ b/app/server/src/analysis/__tests__/sessionClock.test.ts @@ -0,0 +1,24 @@ +import { test } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { rthClosesBetween } from '../sessionClock.ts'; + +test('rthClosesBetween: Friday after-close book on Sunday is 0 sessions', () => { + assert.equal( + rthClosesBetween('2026-08-14T21:30:00.000Z', '2026-08-16T17:00:00.000Z'), + 0, + ); +}); + +test('rthClosesBetween: Wednesday book on Sunday is 3 closes (Wed Thu Fri)', () => { + assert.equal( + rthClosesBetween('2026-08-12T03:46:00.000Z', '2026-08-16T17:00:00.000Z'), + 3, + ); +}); + +test('rthClosesBetween: same timestamp is 0', () => { + assert.equal( + rthClosesBetween('2026-08-14T16:00:00.000Z', '2026-08-14T16:00:00.000Z'), + 0, + ); +}); diff --git a/app/server/src/analysis/dealerExposureEngine.ts b/app/server/src/analysis/dealerExposureEngine.ts index 4da2a58..89f3797 100644 --- a/app/server/src/analysis/dealerExposureEngine.ts +++ b/app/server/src/analysis/dealerExposureEngine.ts @@ -13,6 +13,7 @@ import type { } from '../options/types.ts'; import type { Provenance } from '../cache/CacheRepository.ts'; import { DEALER_FLOW_EXPLAIN_NOTES } from './dealerFlowExplainNotes.ts'; +import { rthClosesBetween } from './sessionClock.ts'; // ----- Public types ----- @@ -85,6 +86,8 @@ export interface DealerMap { symbol: string; spot: number; asOf: string; + quoteAsOf?: string | null; + chainAsOf?: string | null; delayNote: string; delaySeconds: number | null; oiFreshness: OiFreshness; @@ -405,7 +408,7 @@ export function contractGex( return gamma * oi * MULTIPLIER * spot * spot * PCT_MOVE * signForRight(right, convention); } -/** Dollar VEX approximation for one contract lot. */ +/** Dollar VEX for one contract lot. Same notional skeleton as GEX (S² · 1% · 100 · OI). */ export function contractVex( vanna: number, oi: number, @@ -413,8 +416,7 @@ export function contractVex( right: 'call' | 'put', convention: ExposureConvention = 'classic_call_pos_put_neg', ): number { - // Scale vanna similarly: notional delta change for 1% vol move × spot weight. - return vanna * 0.01 * oi * MULTIPLIER * spot * signForRight(right, convention); + return vanna * oi * MULTIPLIER * spot * spot * PCT_MOVE * signForRight(right, convention); } function polarityOf(gex: number, nearZero: number): Polarity { @@ -642,15 +644,21 @@ function buildVelocity( return { asOfPrev: prior.asOf, nodes: nodes.slice(0, 20), growingNearAirPocket }; } -function delayNote(delaySeconds: number | null, oi: OiFreshness): string { - const parts: string[] = []; - if (delaySeconds == null) parts.push('Delay unknown'); - else if (delaySeconds <= 0) parts.push('Quotes real-time'); - else parts.push(`Quotes delayed ~${Math.round(delaySeconds / 60)}m`); - if (oi === 'prior_session') parts.push('Open interest typically from the prior session'); - else if (oi === 'intraday') parts.push('Open interest may update intraday'); - else parts.push('Open interest freshness unknown'); - return parts.join('. ') + '.'; +function delayNote( + _delaySeconds: number | null, + oi: OiFreshness, + chainSessions: number | null, +): string { + const parts: string[] = ['Point-in-time snapshot of one option book']; + if (chainSessions != null && chainSessions >= 2) { + parts.push(`that book is ${chainSessions} regular sessions old`); + } else if (oi === 'prior_session') { + parts.push('open interest is the prior session official print, not a live book'); + } else if (oi === 'intraday') { + parts.push('open interest may update intraday'); + } + parts.push('gamma is local BSM when the vendor omits it'); + return `${parts[0]}. ${parts.slice(1).map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join('. ')}.`; } /** @@ -891,6 +899,11 @@ export function buildDealerMap( } : { isNearOpex: false, note: '' }; + const chainSessions = + surface.chainAsOf && surface.asOf + ? rthClosesBetween(surface.chainAsOf, surface.asOf) + : null; + const coveragePct = totalContracts === 0 ? 0 : Math.round((includedContracts / totalContracts) * 1000) / 10; const quality = scoreGreekQuality({ @@ -906,7 +919,9 @@ export function buildDealerMap( symbol: surface.symbol, spot: surface.spot, asOf: surface.asOf, - delayNote: delayNote(surface.delaySeconds, surface.oiFreshness), + quoteAsOf: surface.quoteAsOf ?? null, + chainAsOf: surface.chainAsOf ?? null, + delayNote: delayNote(surface.delaySeconds, surface.oiFreshness, chainSessions), delaySeconds: surface.delaySeconds, oiFreshness: surface.oiFreshness, providerId: surface.providerId, diff --git a/app/server/src/analysis/dealerFlowExplainNotes.ts b/app/server/src/analysis/dealerFlowExplainNotes.ts index f540904..51078b3 100644 --- a/app/server/src/analysis/dealerFlowExplainNotes.ts +++ b/app/server/src/analysis/dealerFlowExplainNotes.ts @@ -78,7 +78,7 @@ export const DEALER_FLOW_EXPLAIN_NOTES: DealerFlowExplainNotes = { 'Rug-like stack: support-looking structure that can fail and speed moves lower in the model if the soft floor breaks.', }, caveats: [ - 'Quotes may be delayed; open interest is often from the prior session.', + 'This is a point-in-time option-book snapshot. Official OI is the prior session print, not a live book.', 'Numbers are modeled dollar hedging pressure, not reported dealer inventory.', 'This explains a snapshot, not a price prediction.', ], diff --git a/app/server/src/analysis/dealerMapIntegrity.ts b/app/server/src/analysis/dealerMapIntegrity.ts index 280420f..0cacaf5 100644 --- a/app/server/src/analysis/dealerMapIntegrity.ts +++ b/app/server/src/analysis/dealerMapIntegrity.ts @@ -6,6 +6,9 @@ import type { NormalizedOptionSurface } from '../options/types.ts'; import type { DealerMap, DealerMapQuality } from './dealerExposureEngine.ts'; import { isSaneIv } from './dealerExposureEngine.ts'; +import { rthClosesBetween } from './sessionClock.ts'; + +export { rthClosesBetween } from './sessionClock.ts'; export type IntegrityStatus = 'complete' | 'degraded' | 'incomplete'; @@ -49,6 +52,10 @@ export interface DealerIntegrityThresholds { softBsmFillPct: number; /** Soft: bad IV skip above this → degraded (hard uses map quality / include) */ softBadIvSkipPct: number; + /** Soft: option book this many RTH closes behind the evaluation clock. */ + softChainSessions: number; + /** Hard: option book this many RTH closes behind - not a delayed book, a dead one. */ + hardChainSessions: number; } export const DEALER_INTEGRITY_DEFAULTS: DealerIntegrityThresholds = { @@ -61,8 +68,12 @@ export const DEALER_INTEGRITY_DEFAULTS: DealerIntegrityThresholds = { minByStrike: 5, softBsmFillPct: 90, softBadIvSkipPct: 15, + softChainSessions: 2, + hardChainSessions: 3, }; + + function check( id: string, level: 'hard' | 'soft', @@ -253,14 +264,14 @@ export function auditDealerSurface( } } - // Delay is soft informational only (always ok for hard) + // Vendor quote delay is not the map clock. Informational only. if (surface.delaySeconds != null && surface.delaySeconds > 0) { checks.push( check( 'delay', 'soft', true, - `Quotes delayed ~${Math.round(surface.delaySeconds / 60)}m (labeled, not a hard fail)`, + 'Underlying prints may be vendor-delayed; the map clock is the option-book timestamp', ), ); } @@ -270,11 +281,59 @@ export function auditDealerSurface( 'oi_freshness', 'soft', true, - 'OI typically prior session (labeled, not a hard fail)', + 'Official OI is the prior session print (Yahoo does not stream live OI)', ), ); } + const chainAsOf = surface.chainAsOf ?? null; + const evalNow = surface.asOf; + if (chainAsOf && Date.parse(chainAsOf)) { + const sessions = rthClosesBetween(chainAsOf, evalNow); + if (sessions >= t.hardChainSessions) { + checks.push( + check( + 'chain_age', + 'hard', + false, + `Option book is ${sessions} regular sessions old (as of ${chainAsOf}). GEX/VEX are not a current book.`, + ), + ); + } else if (sessions >= t.softChainSessions) { + checks.push( + check( + 'chain_age', + 'soft', + false, + `Option book is ${sessions} regular sessions old (as of ${chainAsOf})`, + ), + ); + } else { + checks.push( + check( + 'chain_age', + 'soft', + true, + sessions === 0 + ? `Option book ${chainAsOf}` + : `Option book ${sessions} session behind (${chainAsOf})`, + ), + ); + } + + const quoteAsOf = surface.quoteAsOf ?? null; + if (quoteAsOf && Date.parse(quoteAsOf) && Date.parse(quoteAsOf) - Date.parse(chainAsOf) > 6 * 3600_000) { + checks.push( + check( + 'spot_vs_chain', + 'soft', + false, + `Spot print (${quoteAsOf}) is newer than the option book (${chainAsOf}). Cells mix a new price with older OI/IV.`, + ), + ); + } + } + const metrics: DealerIntegrityMetrics = { expiries: liveExpiries.length, oiEligible, diff --git a/app/server/src/analysis/dealerMapService.ts b/app/server/src/analysis/dealerMapService.ts index c8c2d84..aa389fc 100644 --- a/app/server/src/analysis/dealerMapService.ts +++ b/app/server/src/analysis/dealerMapService.ts @@ -310,9 +310,10 @@ export async function scheduleDealerMapInputs( await cache?.ensureInDemand(symbol, 'equity'); } catch { /* ignore */ } - // Always want a quote for spot + // Always want a quote for spot - jump the Yahoo pile so the open map is not 5h stale. try { - await queue.queue(`yfinance:quote:${symbol}`); + if (typeof queue.prioritize === "function") await queue.prioritize(`yfinance:quote:${symbol}`); + else await queue.queue(`yfinance:quote:${symbol}`); } catch { /* ignore */ } try { diff --git a/app/server/src/analysis/sessionClock.ts b/app/server/src/analysis/sessionClock.ts new file mode 100644 index 0000000..10a01af --- /dev/null +++ b/app/server/src/analysis/sessionClock.ts @@ -0,0 +1,25 @@ +/** + * Regular-session clock for option-book freshness. + * A session close is Mon–Fri 21:00 UTC (4pm ET DST / 5pm ET standard, close enough + * to count "how many official OI prints we missed"). + */ + +/** Count RTH closes strictly after `fromIso` and at or before `toIso`. */ +export function rthClosesBetween(fromIso: string, toIso: string): number { + const from = Date.parse(fromIso); + const to = Date.parse(toIso); + if (!Number.isFinite(from) || !Number.isFinite(to) || to <= from) return 0; + let n = 0; + const d = new Date(from); + d.setUTCHours(0, 0, 0, 0); + for (let i = 0; i < 21; i++) { + const dow = d.getUTCDay(); + if (dow !== 0 && dow !== 6) { + const close = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 21, 0, 0); + if (close > from && close <= to) n += 1; + } + d.setUTCDate(d.getUTCDate() + 1); + if (d.getTime() > to + 36 * 3600_000) break; + } + return n; +} diff --git a/app/server/src/cache/CacheRepository.ts b/app/server/src/cache/CacheRepository.ts index 019c3ac..6a0b0ef 100644 --- a/app/server/src/cache/CacheRepository.ts +++ b/app/server/src/cache/CacheRepository.ts @@ -42,7 +42,11 @@ export interface SymbolMeta { symbol: string; name?: string | null; sector?: str export interface PriceAdjustment { symbol: string; exDate: string; type: "split" | "dividend"; ratio: number } /** Port CacheRepository depends on to schedule background refreshes. SourceAdapter/AdapterQueue satisfy this. */ -export interface CacheScheduler { queue(key: CacheKey): Promise; } +export interface CacheScheduler { + queue(key: CacheKey): Promise; + /** Jump this key to the front of the next drain (header symbol / page view). */ + prioritize?(key: CacheKey): Promise; +} export interface CacheEntry { value: T | null; provenance: Provenance | null; isStale: boolean; } @@ -89,7 +93,7 @@ interface KindHandler { /** Write value to cache, stamping provenance. */ write(d: DatabaseSync, id: string, value: unknown, provenance: Provenance): void; /** Staleness verdict given the staleness timestamp (null = never cached) and now. */ - isStale(stalenessTs: string | null, now: number): boolean; + isStale(stalenessTs: string | null, now: number, id?: string): boolean; } function tsAgeMs(ts: string | null, now: number): number { @@ -155,16 +159,27 @@ const candlesHandler: KindHandler = { const rows = d.prepare('SELECT ts,o,h,l,c,v,adj_close,observed_at FROM price_candles WHERE symbol=? AND timeframe=? ORDER BY ts ASC').all(symbol, timeframe) as Array>; if (!rows.length) return null; const value: PriceCandle[] = rows.map((r) => ({ ts: r.ts as string, o: r.o as number, h: r.h as number, l: r.l as number, c: r.c as number, v: r.v as number, adjClose: r.adj_close as number | null })); - // Staleness for SWR uses last *bar* ts (not observed_at) so schedule + get agree on lag. - return { value, stalenessTs: rows[rows.length - 1].ts as string }; + const last = rows[rows.length - 1]!; + // Minute TFs: freshness is last fetch (observed_at). Daily: last bar ts so schedule + get agree. + const tf = timeframe ?? ''; + const stalenessTs = (tf === '1m' || tf === '5m') + ? (last.observed_at as string) + : (last.ts as string); + return { value, stalenessTs }; }, write(d, id, value, provenance) { const [symbol, timeframe] = id.split(':'); const ins = d.prepare('INSERT OR REPLACE INTO price_candles (symbol,timeframe,ts,o,h,l,c,v,adj_close,observed_at) VALUES (?,?,?,?,?,?,?,?,?,?)'); for (const c of value as PriceCandle[]) ins.run(symbol, timeframe, c.ts, c.o, c.h, c.l, c.c, c.v, c.adjClose ?? null, provenance.fetchedAt); }, - // Do not thrash on every read: schedule owns EOD refresh. Stale only when absent. - isStale(ts) { return ts === null; }, + // Daily: schedule owns refresh (stale only when absent). 1m/5m: short observed_at TTL. + isStale(ts, now, id) { + if (ts === null) return true; + const tf = id?.split(':')[1]; + if (tf === '1m') return tsAgeMs(ts, now) > 60_000; + if (tf === '5m') return tsAgeMs(ts, now) > TTL_MS.intraday; + return false; + }, }; /** True when daily candles are missing or last bar is older than CANDLE_FRESH_MS. */ @@ -630,7 +645,7 @@ export interface CacheRepository { /** Permanent system pin (rotation universe, SPY, VIX) — survives unsubscribe. */ pinSystemSymbol(symbol: string, tickerKind: TickerKind): Promise; demandSet(): Promise; - getMany(keys: CacheKey[]): Promise>; + getMany(keys: CacheKey[]): Promise>; /** Delete a cache entry by key (or, for wildcard keys ending in `:*`, all matching entries). */ del(key: CacheKey): Promise; /** Underlying DB for schedule TTL checks (queue only). */ @@ -655,7 +670,7 @@ export class CacheRepositoryImpl implements CacheRepository { const h = this.handler(kind); const row = h.read(this._db, id); const now = Date.now(); - let stale = h.isStale(row ? row.stalenessTs : null, now); + let stale = h.isStale(row ? row.stalenessTs : null, now, id); // Incomplete symbol meta (null name) is always treated as stale for SWR re-fetch. if (kind === 'symbol' && row) { const meta = row.value as SymbolMeta; @@ -692,7 +707,7 @@ export class CacheRepositoryImpl implements CacheRepository { const meta = row.value as SymbolMeta; if (!meta?.name) return true; } - return h.isStale(row ? row.stalenessTs : null, Date.now()); + return h.isStale(row ? row.stalenessTs : null, Date.now(), id); } private ensureDemandRow(symbol: string, tickerKind: TickerKind): void { @@ -702,10 +717,14 @@ export class CacheRepositoryImpl implements CacheRepository { } /** Queue only what is missing or past TTL — no refcount change. */ - private async queueIfNeeded(symbol: string): Promise { + private async queueIfNeeded(symbol: string, opts?: { prioritizeQuote?: boolean }): Promise { const d = this._db; if (needsQuoteRefresh(d, symbol)) { - try { await this._scheduler.queue(`yfinance:quote:${symbol}`); } catch { /* ignore */ } + const key = `yfinance:quote:${symbol}`; + try { + if (opts?.prioritizeQuote && this._scheduler.prioritize) await this._scheduler.prioritize(key); + else await this._scheduler.queue(key); + } catch { /* ignore */ } } if (needsCandleRefresh(d, symbol)) { try { await this._scheduler.queue(`yfinance:candles:${symbol}:1d`); } catch { /* ignore */ } @@ -754,7 +773,7 @@ export class CacheRepositoryImpl implements CacheRepository { this._db.prepare( "UPDATE symbol_demand SET tier = MIN(tier, 2), last_viewed_at = ?, in_demand = 1 WHERE symbol=?", ).run(now, symbol); - await this.queueIfNeeded(symbol); + await this.queueIfNeeded(symbol, { prioritizeQuote: true }); } async pinSystemSymbol(symbol: string, tickerKind: TickerKind): Promise { @@ -778,10 +797,10 @@ export class CacheRepositoryImpl implements CacheRepository { 'SELECT symbol FROM symbol_demand WHERE in_demand = 1 OR COALESCE(system_pin, 0) = 1 ORDER BY symbol', ).all() as Array<{ symbol: string }>).map((r) => r.symbol); } - async getMany(keys: CacheKey[]): Promise> { + async getMany(keys: CacheKey[]): Promise> { return Promise.all(keys.map(async (key) => { const e = await this.get(key); - return { key, value: e.value, isStale: e.isStale }; + return { key, value: e.value, isStale: e.isStale, fetchedAt: e.provenance?.fetchedAt ?? null }; })); } private clearKvCachePrefix(prefix: string): void { diff --git a/app/server/src/cache/__tests__/CacheRepository.test.ts b/app/server/src/cache/__tests__/CacheRepository.test.ts index b0e8886..19482f0 100644 --- a/app/server/src/cache/__tests__/CacheRepository.test.ts +++ b/app/server/src/cache/__tests__/CacheRepository.test.ts @@ -141,6 +141,42 @@ test('candles are permanent: not stale once present, stale when absent', async ( assert.equal(scheduler.queued.length, 0); }); +test('5m candles do not overwrite 1d rows', async () => { + const { repo, db } = setup(); + const daily: PriceCandle[] = [{ ts: '2026-08-15T00:00:00.000Z', o: 100, h: 101, l: 99, c: 100.5, v: 1e6, adjClose: 100.5 }]; + const five: PriceCandle[] = [{ ts: '2026-08-15T14:30:00.000Z', o: 100.2, h: 100.4, l: 100.1, c: 100.3, v: 2e4, adjClose: 100.3 }]; + await repo.set('yfinance:candles:NVDA:1d', daily, 'daily_permanent', { fetchedAt: iso(), sourceKind: 'yfinance' }); + await repo.set('yfinance:candles:NVDA:5m', five, 'daily_permanent', { fetchedAt: iso(), sourceKind: 'yfinance' }); + const d = await repo.get('yfinance:candles:NVDA:1d'); + const m = await repo.get('yfinance:candles:NVDA:5m'); + assert.equal(d.value?.[0]?.c, 100.5); + assert.equal(m.value?.[0]?.c, 100.3); + const n = db.prepare('SELECT timeframe, COUNT(*) AS n FROM price_candles WHERE symbol=? GROUP BY timeframe').all('NVDA') as Array<{ timeframe: string; n: number }>; + assert.equal(n.find((r) => r.timeframe === '1d')?.n, 1); + assert.equal(n.find((r) => r.timeframe === '5m')?.n, 1); +}); + +test('fresh 5m candles are not stale; old observed_at is', async () => { + const { repo, scheduler } = setup(); + const five: PriceCandle[] = [{ ts: '2026-08-15T14:30:00.000Z', o: 1, h: 2, l: 1, c: 1.5, v: 10, adjClose: 1.5 }]; + await repo.set('yfinance:candles:NVDA:5m', five, 'daily_permanent', { fetchedAt: iso(-10 * 60_000), sourceKind: 'yfinance' }); + scheduler.reset(); + let e = await repo.get('yfinance:candles:NVDA:5m'); + assert.equal(e.isStale, true); + assert.ok(scheduler.queued.includes('yfinance:candles:NVDA:5m')); + await repo.set('yfinance:candles:NVDA:5m', five, 'daily_permanent', { fetchedAt: iso(), sourceKind: 'yfinance' }); + scheduler.reset(); + e = await repo.get('yfinance:candles:NVDA:5m'); + assert.equal(e.isStale, false); + assert.equal(scheduler.queued.length, 0); +}); + +test('subscribe does not enqueue 1m or 5m', async () => { + const { repo, scheduler } = setup(); + await repo.subscribe('NVDA', 'equity'); + assert.ok(!scheduler.queued.some((k) => k.includes(':1m') || k.endsWith(':5m'))); +}); + test('symbol_meta stale after 7d, fresh within', async () => { const { repo, scheduler } = setup(); const s: SymbolMeta = { symbol: 'NVDA', name: 'NVIDIA', sector: 'Technology', tickerKind: 'equity' }; diff --git a/app/server/src/confluence/__tests__/candleProvider.test.ts b/app/server/src/confluence/__tests__/candleProvider.test.ts index 94737a9..4a9c3a0 100644 --- a/app/server/src/confluence/__tests__/candleProvider.test.ts +++ b/app/server/src/confluence/__tests__/candleProvider.test.ts @@ -34,7 +34,7 @@ class FakeCache implements CacheRepository { async ensureInDemand(): Promise {} async pinSystemSymbol(): Promise {} async demandSet(): Promise { return []; } - async getMany(): Promise> { return []; } + async getMany(): Promise> { return []; } async del(): Promise {} readonly db: never = undefined as never; } @@ -138,4 +138,20 @@ describe('CacheCandleProvider', () => { assert.deepEqual(r.candles, []); assert.match(r.asOf, /^\d{4}-\d{2}-\d{2}$/); }); + + it('resolveAsOf slices to the cutoff and never folds a live quote', async () => { + const cache = new FakeCache() + .setValue('yfinance:candles:SPY:1d', [ + bar('2026-08-07', 100), + bar('2026-08-10', 105), + bar('2026-08-11', 110), + ], false) + .setValue('yfinance:quote:SPY', { symbol: 'SPY', price: 999 }, false); + const r = await new CacheCandleProvider(cache).resolveAsOf('SPY', '1d', '2026-08-10'); + assert.equal(r.candles.length, 2); + assert.equal(r.asOf, '2026-08-10'); + assert.equal(r.realtimeFolded, false); + assert.equal(r.lastBar, 'yfinance'); + assert.equal(r.candles[r.candles.length - 1].c, 105); + }); }); \ No newline at end of file diff --git a/app/server/src/confluence/__tests__/confluenceEngine.test.ts b/app/server/src/confluence/__tests__/confluenceEngine.test.ts index 1d82c02..cd59d51 100644 --- a/app/server/src/confluence/__tests__/confluenceEngine.test.ts +++ b/app/server/src/confluence/__tests__/confluenceEngine.test.ts @@ -13,7 +13,7 @@ import { createCacheRepository, type CacheRepository, type CacheEntry, type Pric import { FakeSourceAdapter } from '../../adapters/SourceAdapter.ts'; import { AdapterQueue } from '../../queue/AdapterQueue.ts'; import { seedConfluence, CONFLUENCE_UNIVERSE, BENCHMARK_SYMBOL } from '../confluenceSeed.ts'; -import { runConfluenceEvaluationCycle } from '../confluenceEngine.ts'; +import { runConfluenceEvaluationCycle, runConfluenceReplay } from '../confluenceEngine.ts'; // ---- fake cache that returns whatever we seeded --------------------------------- @@ -31,7 +31,7 @@ class FakeCache implements CacheRepository { async ensureInDemand(): Promise {} async pinSystemSymbol(): Promise {} async demandSet(): Promise { return []; } - async getMany(): Promise> { return []; } + async getMany(): Promise> { return []; } async del(): Promise {} readonly db: never = undefined as never; } @@ -126,4 +126,31 @@ describe('runConfluenceEvaluationCycle', () => { assert.equal(summary.symbolsSkipped.length, 1); assert.equal(summary.symbolsSkipped[0].symbol, CONFLUENCE_UNIVERSE[0].symbol); }); -}); \ No newline at end of file +}); + +describe('runConfluenceReplay', () => { + it('fills missing as-of evaluations from cached candles and is idempotent', async () => { + const first = await runConfluenceReplay(db, cache as unknown as CacheRepository, { + symbols: ['PLTR'], + lookbackDays: 30, + budgetDaysPerSymbol: 30, + symbolsPerTick: 1, + }); + assert.ok(first.evaluationsStored > 0, 'replay stored historical evaluations'); + assert.ok(first.symbolsTouched.includes('PLTR')); + + const second = await runConfluenceReplay(db, cache as unknown as CacheRepository, { + symbols: ['PLTR'], + lookbackDays: 30, + budgetDaysPerSymbol: 30, + symbolsPerTick: 1, + }); + assert.equal(second.evaluationsStored, 0); + assert.equal(confluenceRepo.countEvaluations('PLTR', repoFullRack()), first.evaluationsStored / 3); + }); +}); + +function repoFullRack(): string { + const rack = confluenceRepo.listSystemRacks().find((r) => r.id === 'confluence-full'); + return rack?.id ?? confluenceRepo.listSystemRacks()[0]!.id; +} \ No newline at end of file diff --git a/app/server/src/confluence/__tests__/confluenceRack.test.ts b/app/server/src/confluence/__tests__/confluenceRack.test.ts index 2fde86e..8414998 100644 --- a/app/server/src/confluence/__tests__/confluenceRack.test.ts +++ b/app/server/src/confluence/__tests__/confluenceRack.test.ts @@ -123,6 +123,17 @@ describe('evaluateRack', () => { ]); assert.equal(ev.quality, 'mixed'); }); + + it('scales a fired slot by its reliability weight; missing weight stays 1.0', () => { + const unweighted = evaluateRack('PLTR', '2026-01-05', [fired('goldenCross')]); + assert.equal(unweighted.bullEvidence, 1); + + const half = evaluateRack('PLTR', '2026-01-05', [fired('goldenCross')], new Map([['goldenCross', 0.5]])); + assert.equal(half.bullEvidence, 0.5); + + const boosted = evaluateRack('PLTR', '2026-01-05', [fired('goldenCross')], new Map([['goldenCross', 1.25]])); + assert.equal(boosted.bullEvidence, 1.25); + }); }); describe('detectPictureChange', () => { diff --git a/app/server/src/confluence/__tests__/confluenceSeed.test.ts b/app/server/src/confluence/__tests__/confluenceSeed.test.ts index 8c70e9a..82fe5fe 100644 --- a/app/server/src/confluence/__tests__/confluenceSeed.test.ts +++ b/app/server/src/confluence/__tests__/confluenceSeed.test.ts @@ -83,9 +83,10 @@ describe('defineSystemRackPresets', () => { }); describe('seedConfluence', () => { - it('pins16 symbols (15 universe + benchmark) and creates 3 system racks', async () => { + it('pins the research universe, learning sector ETFs, and benchmark, and creates 3 system racks', async () => { const { symbolsPinned, racksCreated } = await seedConfluence(db, cache); - assert.equal(symbolsPinned, 16); + // 11 GICS + SPY + 15 research names (SMH is extra; no other overlap) + assert.equal(symbolsPinned, 27); assert.equal(racksCreated, 3); const systemRacks = repo.listSystemRacks(); assert.equal(systemRacks.length, 3); diff --git a/app/server/src/confluence/__tests__/confluenceZones.test.ts b/app/server/src/confluence/__tests__/confluenceZones.test.ts new file mode 100644 index 0000000..47aa155 --- /dev/null +++ b/app/server/src/confluence/__tests__/confluenceZones.test.ts @@ -0,0 +1,283 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import type { PriceCandle } from '../../cache/CacheRepository.ts'; +import type { PictureQuality, SlotAssessment } from '../confluenceRack.ts'; +import { + attachOutcomes, + collapseZones, + deriveZoneRules, + findOnsets, + ONSET_EXTREME_15_SLIDE, + ONSET_TURN_ON, + lastNZones, + matchRule, + zoneOnsetWindow, + ZONE_DISPLAY_BARS, + recentZonesForSymbol, + selectWinner, + type EvaluationPoint, + type ZonePredicate, +} from '../confluenceZones.ts'; + +const fired = (id: string): SlotAssessment => ({ id, state: 'fired' }); +const notFired = (id: string): SlotAssessment => ({ id, state: 'not-fired' }); + +function ev(asOf: string, quality: PictureQuality, net = 2, extra: Partial = {}): EvaluationPoint { + const bullish = quality.includes('bullish'); + return { + symbol: extra.symbol ?? 'XLK', + asOf, + quality, + netEvidence: extra.netEvidence ?? net, + bullEvidence: extra.bullEvidence ?? (bullish ? Math.max(net, 2) : 0.2), + bearEvidence: extra.bearEvidence ?? (bullish ? 0.2 : Math.max(-net, 2)), + assessments: extra.assessments ?? [fired('goldenCross')], + }; +} + +function candles(start: string, days: number, startPx = 100, daily = 0.002): PriceCandle[] { + const out: PriceCandle[] = []; + const t0 = Date.parse(`${start}T00:00:00Z`); + for (let i = 0; i < days; i++) { + const ts = new Date(t0 + i * 86400000).toISOString().slice(0, 10); + const c = startPx * Math.pow(1 + daily, i); + out.push({ ts, o: c, h: c * 1.01, l: c * 0.99, c, v: 1_000_000, adjClose: c }); + } + return out; +} + +const entryPred: ZonePredicate = { + id: 'entry-mod-strong', + label: 'Moderate or strong bullish picture', + qualities: ['moderate-bullish', 'strong-bullish'], +}; +const exitPred: ZonePredicate = { + id: 'exit-mod-strong', + label: 'Moderate or strong bearish picture', + qualities: ['moderate-bearish', 'strong-bearish'], +}; + +describe('matchRule', () => { + it('matches a quality band and rejects mixed', () => { + assert.equal(matchRule(ev('2024-01-02', 'moderate-bullish'), entryPred), true); + assert.equal(matchRule(ev('2024-01-02', 'mixed'), entryPred), false); + }); + + it('requires a named slot to have fired when listed', () => { + const pred: ZonePredicate = { id: 'p', label: 'pullback', qualities: ['moderate-bullish'], requireAnyFired: ['pullbackToEMA21'] }; + assert.equal(matchRule(ev('2024-01-02', 'moderate-bullish', 2, { assessments: [fired('goldenCross')] }), pred), false); + assert.equal(matchRule(ev('2024-01-02', 'moderate-bullish', 2, { assessments: [fired('pullbackToEMA21')] }), pred), true); + }); +}); + +describe('collapseZones', () => { + it('merges a one-session gap and splits a two-session gap', () => { + const series = [ + ev('2024-01-02', 'moderate-bullish'), + ev('2024-01-03', 'moderate-bullish'), + ev('2024-01-04', 'mixed', 0), + ev('2024-01-05', 'moderate-bullish'), + ev('2024-01-08', 'mixed', 0), + ev('2024-01-09', 'mixed', 0), + ev('2024-01-10', 'moderate-bullish'), + ]; + const zones = collapseZones(series, 'entry', entryPred); + assert.equal(zones.length, 2); + assert.equal(zones[0].startAsOf, '2024-01-02'); + assert.equal(zones[0].endAsOf, '2024-01-05'); + assert.equal(zones[1].startAsOf, '2024-01-10'); + assert.equal(zones[1].endAsOf, '2024-01-10'); + }); + + it('closes an entry zone when the exit predicate fires', () => { + const series = [ + ev('2024-01-02', 'moderate-bullish'), + ev('2024-01-03', 'moderate-bullish'), + ev('2024-01-04', 'moderate-bearish', -2, { assessments: [fired('deathCross')] }), + ev('2024-01-05', 'moderate-bullish'), + ]; + const zones = collapseZones(series, 'entry', entryPred, exitPred); + assert.equal(zones.length, 2); + assert.equal(zones[0].endAsOf, '2024-01-03'); + assert.equal(zones[1].startAsOf, '2024-01-05'); + }); + + it('lastNZones keeps the newest windows', () => { + const zones = collapseZones([ + ev('2024-01-02', 'moderate-bullish'), + ev('2024-01-10', 'mixed', 0), + ev('2024-01-11', 'mixed', 0), + ev('2024-02-01', 'moderate-bullish'), + ev('2024-02-10', 'mixed', 0), + ev('2024-02-11', 'mixed', 0), + ev('2024-03-01', 'strong-bullish'), + ], 'entry', entryPred); + const last = lastNZones(zones, 2); + assert.equal(last.length, 2); + assert.equal(last[0].startAsOf, '2024-02-01'); + assert.equal(last[1].startAsOf, '2024-03-01'); + }); +}); + +describe('zoneOnsetWindow', () => { + it('caps a long open stretch to the first 4 weeks from start', () => { + const px = candles('2025-06-20', 400, 10, 0.01); + const onset = zoneOnsetWindow('2025-06-20', '2026-08-12', px); + assert.ok(onset); + assert.equal(onset.startAsOf, '2025-06-20'); + const startIdx = px.findIndex((c) => c.ts.slice(0, 10) === '2025-06-20'); + const endIdx = px.findIndex((c) => c.ts.slice(0, 10) === onset.endAsOf); + assert.equal(endIdx - startIdx + 1, ZONE_DISPLAY_BARS); + assert.ok(onset.endAsOf < '2026-08-12'); + }); + + it('does not extend past a short natural end', () => { + const px = candles('2025-06-04', 40, 10, 0.01); + const onset = zoneOnsetWindow('2025-06-04', '2025-06-16', px); + assert.ok(onset); + assert.equal(onset.startAsOf, '2025-06-04'); + assert.equal(onset.endAsOf, '2025-06-16'); + }); + + it('returns null when the start bar is missing', () => { + const px = candles('2025-07-01', 20, 10, 0); + assert.equal(zoneOnsetWindow('2025-06-01', '2025-07-10', px), null); + }); +}); + +describe('findOnsets / pullback re-entry', () => { + it('opens a second entry window on an N-session low inside a still-bullish picture', () => { + const evals: EvaluationPoint[] = []; + const px: PriceCandle[] = []; + const start = Date.UTC(2025, 5, 2); // Jun 2 + let price = 20; + for (let i = 0; i < 80; i++) { + const asOf = new Date(start + i * 86400000).toISOString().slice(0, 10); + // Ramp, then a 15-day dip around day 50, then ramp again. + if (i < 40) price *= 1.01; + else if (i < 55) price *= 0.985; + else price *= 1.012; + evals.push(ev(asOf, 'moderate-bullish', 2.5)); + px.push({ ts: asOf, o: price, h: price * 1.01, l: price * 0.99, c: price, v: 1_000_000, adjClose: price }); + } + const onsets = findOnsets(evals, 'entry', entryPred, exitPred, px, ONSET_EXTREME_15_SLIDE); + assert.ok(onsets.length >= 2, `expected a re-entry, got ${onsets.join(', ')}`); + assert.equal(onsets[0], evals[0].asOf); + // The second onset should sit in the dip, not on day 1. + assert.ok(onsets[1] > evals[30].asOf); + const turnOn = findOnsets(evals, 'entry', entryPred, exitPred, px, ONSET_TURN_ON); + assert.equal(turnOn.length, 1); + }); + + it('slides the onset to a deeper confirmed trough inside the quiet gap', () => { + const evals: EvaluationPoint[] = []; + const px: PriceCandle[] = []; + const start = Date.UTC(2025, 5, 2); + // 25 days: drift down to day 10, bounce, then a lower low at day 18, then up. + const closes = [ + 20, 19.5, 19.2, 18.8, 18.4, 18.0, 17.6, 17.2, 16.8, 16.4, + 16.0, 16.5, 16.8, 16.6, 16.2, 15.8, 15.4, 15.0, 14.4, 15.2, + 15.6, 16.0, 16.4, 16.8, 17.2, + ]; + for (let i = 0; i < closes.length; i++) { + const asOf = new Date(start + i * 86400000).toISOString().slice(0, 10); + const price = closes[i]; + evals.push(ev(asOf, 'moderate-bullish', 2)); + px.push({ ts: asOf, o: price, h: price, l: price, c: price, v: 1, adjClose: price }); + } + const onsets = findOnsets(evals, 'entry', entryPred, exitPred, px, ONSET_EXTREME_15_SLIDE); + assert.ok(onsets.length >= 1); + // The first stretch slides to the deeper confirmed trough (14.4) before the bounce. + assert.equal(onsets[onsets.length - 1], evals[18].asOf); + }); + + it('does not re-enter without a quiet gap', () => { + const evals: EvaluationPoint[] = []; + const px: PriceCandle[] = []; + const start = Date.UTC(2025, 5, 2); + let price = 20; + for (let i = 0; i < 12; i++) { + const asOf = new Date(start + i * 86400000).toISOString().slice(0, 10); + price *= i === 11 ? 0.9 : 1.01; + evals.push(ev(asOf, 'moderate-bullish', 2)); + px.push({ ts: asOf, o: price, h: price, l: price, c: price, v: 1, adjClose: price }); + } + const onsets = findOnsets(evals, 'entry', entryPred, exitPred, px, ONSET_EXTREME_15_SLIDE); + assert.equal(onsets.length, 1); + }); +}); + +describe('attachOutcomes', () => { + it('marks a rising stretch confirmed for an entry zone', () => { + const series = [ev('2024-01-02', 'moderate-bullish')]; + const zones = attachOutcomes(collapseZones(series, 'entry', entryPred), candles('2024-01-02', 40, 100, 0.01)); + assert.equal(zones[0].verdict, 'confirmed'); + assert.ok((zones[0].forwardReturn ?? 0) > 0); + }); + + it('leaves a late zone open when forward bars are missing', () => { + const series = [ev('2024-01-20', 'moderate-bullish')]; + const zones = attachOutcomes(collapseZones(series, 'entry', entryPred), candles('2024-01-02', 25, 100, 0.01)); + assert.equal(zones[0].verdict, 'open'); + }); +}); + +describe('selectWinner / deriveZoneRules', () => { + it('refuses a noisy winner and falls back to the conservative quality band', () => { + // Short mixed series: not enough resolved train zones. + const evals: EvaluationPoint[] = []; + for (let i = 0; i < 30; i++) { + const d = new Date(Date.UTC(2023, 0, 2 + i)).toISOString().slice(0, 10); + evals.push(ev(d, i % 2 === 0 ? 'mixed' : 'sparse', 0)); + } + const px = new Map([['XLK', candles('2023-01-02', 80, 100, 0)]]); + const rule = selectWinner('entry', [{ id: 'entry-net-3', label: 'Net 3', minNetEvidence: 3 }], evals, px, entryPred); + assert.equal(rule.source, 'baseline'); + assert.equal(rule.predicate.id, entryPred.id); + }); + + it('picks the template that beats baseline on the held-out year', () => { + const evals: EvaluationPoint[] = []; + const px: PriceCandle[] = []; + // Two years of daily points. Moderate-bullish days sit at local troughs + // and are followed by a rise; other days drift flat then drop. + let price = 100; + const start = Date.UTC(2023, 0, 2); + for (let i = 0; i < 520; i++) { + const asOf = new Date(start + i * 86400000).toISOString().slice(0, 10); + const isSignal = i % 25 === 0; + if (isSignal) { + evals.push(ev(asOf, 'moderate-bullish', 2.5, { assessments: [fired('goldenCross'), fired('pullbackToEMA21')] })); + price = price * 0.98; + } else { + evals.push(ev(asOf, 'mixed', 0.2, { assessments: [notFired('goldenCross')] })); + price = price * (i % 25 < 12 ? 1.008 : 0.997); + } + px.push({ ts: asOf, o: price, h: price * 1.01, l: price * 0.99, c: price, v: 1_000_000, adjClose: price }); + } + const candlesBySymbol = new Map([['XLK', px]]); + const { entry } = deriveZoneRules(evals, candlesBySymbol); + // Either validated (if the synthetic series beat baseline by 10pp) or the + // conservative fallback. Never a random high-threshold that never fires. + assert.ok(entry.predicate.id === 'entry-mod-strong' || entry.source === 'validated' || entry.source === 'baseline'); + assert.ok(entry.predicate.qualities?.includes('moderate-bullish')); + }); +}); + +describe('recentZonesForSymbol', () => { + it('returns the last few entry and exit windows with outcomes', () => { + const evals = [ + ev('2024-01-02', 'moderate-bullish'), + ev('2024-01-03', 'moderate-bullish'), + ev('2024-03-01', 'moderate-bearish', -2, { assessments: [fired('deathCross')] }), + ev('2024-03-02', 'moderate-bearish', -2, { assessments: [fired('deathCross')] }), + ev('2024-06-03', 'strong-bullish', 4), + ]; + const { entries, exits } = recentZonesForSymbol(evals, candles('2024-01-02', 200, 100, 0.003), entryPred, exitPred, 3, 3); + assert.ok(entries.length >= 1); + assert.ok(exits.length >= 1); + assert.equal(entries[entries.length - 1].startAsOf, '2024-06-03'); + assert.equal(exits[0].startAsOf, '2024-03-01'); + }); +}); diff --git a/app/server/src/confluence/candleProvider.ts b/app/server/src/confluence/candleProvider.ts index a4e5ab3..dfe9087 100644 --- a/app/server/src/confluence/candleProvider.ts +++ b/app/server/src/confluence/candleProvider.ts @@ -53,6 +53,11 @@ export interface CandleResolution { */ export interface CandleProvider { resolve(symbol: string, granularity: SlotGranularity): Promise; + /** + * Honest historical cut: candles with ts <= asOf only. Never folds a live quote. + * Used by replay so a past evaluation cannot see tomorrow's close. + */ + resolveAsOf(symbol: string, granularity: SlotGranularity, asOf: string): Promise; } // --------------------------------------------------------------------------- @@ -173,4 +178,22 @@ export class CacheCandleProvider implements CandleProvider { isStale, }; } + + async resolveAsOf(symbol: string, granularity: SlotGranularity, asOf: string): Promise { + const sym = symbol.toUpperCase(); + const cutoff = asOf.slice(0, 10); + const entry = await this._cache.get(`yfinance:candles:${sym}:${granularity}`); + const stored = (entry?.value ?? []).slice(); + const candles = stored.filter((c) => (c.ts ?? '').slice(0, 10) <= cutoff); + const lastTs = candles.length > 0 ? (candles[candles.length - 1].ts ?? '').slice(0, 10) : ''; + return { + symbol: sym, + granularity, + candles, + asOf: lastTs || cutoff, + lastBar: 'yfinance', + realtimeFolded: false, + isStale: entry?.isStale ?? true, + }; + } } \ No newline at end of file diff --git a/app/server/src/confluence/confluenceEngine.ts b/app/server/src/confluence/confluenceEngine.ts index 2da02cd..0bfe7c3 100644 --- a/app/server/src/confluence/confluenceEngine.ts +++ b/app/server/src/confluence/confluenceEngine.ts @@ -30,8 +30,25 @@ import { buildSeasonalitySnapshot } from '../analysis/seasonality.ts'; import { resolveCorridorSnapshot } from './corridorData.ts'; import { evaluateRack, type ConfluenceEvaluation, type SlotAssessment } from './confluenceRack.ts'; import { ConfluenceRepository, type ConfluenceRack } from '../db/confluenceRepository.ts'; -import { resolveSignalHistory } from './confluenceBacktest.ts'; -import { CONFLUENCE_UNIVERSE, BENCHMARK_SYMBOL } from './confluenceSeed.ts'; +import { resolveSignalHistory, signalHistoryToStats } from './confluenceBacktest.ts'; +import { + CONFLUENCE_UNIVERSE, + BENCHMARK_SYMBOL, + CONFLUENCE_LEARNING_UNIVERSE, + REPLAY_LOOKBACK_DAYS, +} from './confluenceSeed.ts'; +import { + deriveZoneRules, + FALLBACK_ENTRY, + FALLBACK_EXIT, + recentZonesForSymbol, + attachOutcomes, + collapseZones, + scoreZones, + type EvaluationPoint, + type ZonePredicate, + type ZoneRuleStats, +} from './confluenceZones.ts'; // --------------------------------------------------------------------------- // Unwired slots → honest fallback assessments @@ -155,7 +172,8 @@ export async function runConfluenceEvaluationCycle( continue; } - const evaluation: ConfluenceEvaluation = evaluateRack(symbol, asOf, sliced); + const weights = reliabilityWeights(db); + const evaluation: ConfluenceEvaluation = evaluateRack(symbol, asOf, sliced, weights); const firesLogged = evaluation.assessments.filter((a) => a.state === 'fired').length; repo.saveEvaluation(evaluation, rack.id, randomUUID()); repo.logSignalFires(evaluation, rack.id); @@ -185,4 +203,307 @@ function corridorUnavailableAssessments(): SlotAssessment[] { 'corridorUpsideHigh', 'corridorUpsideLow', 'spyCorridorCheap', 'spyCorridorStretched', ].map((id) => ({ id, state: 'not-fired', note: 'No valuation-corridor snapshot cached for this symbol yet.' })); +} + +export const REPLAY_DAYS_PER_SYMBOL = 40; +export const REPLAY_SYMBOLS_PER_TICK = 3; +export const LEARNING_REPLAY_DAYS = 120; +export const LEARNING_REPLAY_SYMBOLS = 6; +export const LEARNING_MIN_DAYS = 400; + +export interface ReplayOptions { + symbols?: string[]; + lookbackDays?: number; + budgetDaysPerSymbol?: number; + symbolsPerTick?: number; + /** When true, do not apply live reliability weights (historical ledger stays raw). */ + unweighted?: boolean; +} + +export interface ReplayRunSummary { + symbolsTouched: string[]; + evaluationsStored: number; + signalsLogged: number; + remainingDays: number; + complete: boolean; +} + +function reliabilityWeights(db: DatabaseSync): Map { + const stats = signalHistoryToStats(db); + // Unresolved / thin samples stay at the rack default (1.0). Only apply a + // learned weight once the slot has enough follow-through to score. + return new Map(stats.filter((s) => s.resolvedFires >= 2).map((s) => [s.slotId, s.reliabilityWeight])); +} + +function toPoint(ev: { symbol: string; asOf: string; quality: import('./confluenceRack.ts').PictureQuality; netEvidence: number; bullEvidence: number; bearEvidence: number; assessments: SlotAssessment[] }): EvaluationPoint { + return { + symbol: ev.symbol, + asOf: ev.asOf, + quality: ev.quality, + netEvidence: ev.netEvidence, + bullEvidence: ev.bullEvidence, + bearEvidence: ev.bearEvidence, + assessments: ev.assessments, + }; +} + +/** + * Replay missing as-of evaluations from cached candles. Honest cut: no live + * quote fold-in, no today's corridor snapshot applied to a past date. + */ +export async function runConfluenceReplay( + db: DatabaseSync, + cache: CacheRepository, + opts: ReplayOptions = {}, +): Promise { + const repo = new ConfluenceRepository(db); + const provider: CandleProvider = new CacheCandleProvider(cache); + const racks = repo.listSystemRacks(); + if (racks.length === 0) throw new Error('confluence replay: no system racks seeded'); + + const lookback = opts.lookbackDays ?? REPLAY_LOOKBACK_DAYS; + const budget = opts.budgetDaysPerSymbol ?? REPLAY_DAYS_PER_SYMBOL; + const symbolCap = opts.symbolsPerTick ?? REPLAY_SYMBOLS_PER_TICK; + const symbols = (opts.symbols ?? [...CONFLUENCE_LEARNING_UNIVERSE]) + .map((s) => s.toUpperCase()); + const unique = [...new Set(symbols)]; + + const summary: ReplayRunSummary = { + symbolsTouched: [], + evaluationsStored: 0, + signalsLogged: 0, + remainingDays: 0, + complete: true, + }; + + let symbolsUsed = 0; + for (const symbol of unique) { + if (symbolsUsed >= symbolCap) { + summary.complete = false; + break; + } + const daily = await provider.resolveAsOf(symbol, '1d', '9999-12-31'); + if (daily.candles.length === 0) continue; + + const dates = daily.candles.map((c) => (c.ts ?? '').slice(0, 10)).filter(Boolean); + const window = dates.slice(Math.max(0, dates.length - lookback)); + const primaryRack = racks[0]; + const have = new Set(repo.listEvaluationAsOfs(symbol, primaryRack.id)); + const missing = window.filter((d) => !have.has(d)); + summary.remainingDays += missing.length; + if (missing.length === 0) continue; + + const slice = missing.slice(0, budget); + if (missing.length > slice.length) summary.complete = false; + symbolsUsed += 1; + summary.symbolsTouched.push(symbol); + + const weeklyFull = await provider.resolveAsOf(symbol, '1wk', '9999-12-31'); + const benchFull = await provider.resolveAsOf(BENCHMARK_SYMBOL, '1d', '9999-12-31'); + + for (const asOf of slice) { + const dailyCut = daily.candles.filter((c) => (c.ts ?? '').slice(0, 10) <= asOf); + const weeklyCut = weeklyFull.candles.filter((c) => (c.ts ?? '').slice(0, 10) <= asOf); + const benchCut = benchFull.candles.filter((c) => (c.ts ?? '').slice(0, 10) <= asOf); + if (dailyCut.length === 0) continue; + + const wired: SlotAssessment[] = [ + ...evaluateTechnicalSlots(symbol, dailyCut, { weekly: weeklyCut, benchmarkDaily: benchCut }), + ...evaluateSeasonalSlots(buildSeasonalitySnapshot(symbol, dailyCut), asOf), + ...corridorUnavailableAssessments(), + ]; + const assessments = [...wired, ...unwiredFallbacks(wired)]; + + for (const rack of racks) { + if (repo.getEvaluation(symbol, asOf, rack.id)) continue; + const rackSlots = new Set(rack.slotIds); + const sliced = assessments.filter((a) => rackSlots.has(a.id)); + if (sliced.length === 0) continue; + const evaluation: ConfluenceEvaluation = evaluateRack(symbol, asOf, sliced); + repo.saveEvaluation(evaluation, rack.id, randomUUID()); + repo.logSignalFires(evaluation, rack.id); + summary.evaluationsStored += 1; + summary.signalsLogged += evaluation.assessments.filter((a) => a.state === 'fired').length; + } + } + } + + await resolveSignalHistory(db, async (sym) => { + const res = await provider.resolveAsOf(sym.toUpperCase(), '1d', '9999-12-31'); + return res.candles; + }); + + return summary; +} + +export interface LearningLedgerStatus { + ready: boolean; + symbols: Array<{ symbol: string; evaluatedDays: number; targetDays: number; ready: boolean }>; +} + +export async function learningLedgerStatus( + db: DatabaseSync, + cache: CacheRepository, + lookback = REPLAY_LOOKBACK_DAYS, +): Promise { + const repo = new ConfluenceRepository(db); + const rack = repo.listSystemRacks()[0]; + const provider = new CacheCandleProvider(cache); + const symbols: LearningLedgerStatus['symbols'] = []; + if (!rack) return { ready: false, symbols }; + for (const symbol of CONFLUENCE_LEARNING_UNIVERSE) { + const n = repo.countEvaluations(symbol, rack.id); + const daily = await provider.resolveAsOf(symbol, '1d', '9999-12-31'); + const available = daily.candles.length; + const target = Math.min(lookback, available > 0 ? available : lookback); + const ready = n >= Math.min(target, LEARNING_MIN_DAYS) || (target < LEARNING_MIN_DAYS && n >= target); + symbols.push({ symbol, evaluatedDays: n, targetDays: target, ready }); + } + return { ready: symbols.every((s) => s.ready), symbols }; +} + +export async function fillLearningLedger( + db: DatabaseSync, + cache: CacheRepository, + maxMs = 90_000, +): Promise { + const started = Date.now(); + let last: ReplayRunSummary = { + symbolsTouched: [], + evaluationsStored: 0, + signalsLogged: 0, + remainingDays: 0, + complete: false, + }; + while (Date.now() - started < maxMs) { + const status = await learningLedgerStatus(db, cache); + if (status.ready) return { ...last, complete: true, ledger: status }; + const batch = await runConfluenceReplay(db, cache, { + symbols: CONFLUENCE_LEARNING_UNIVERSE, + symbolsPerTick: 12, + budgetDaysPerSymbol: 80, + }); + last = { + symbolsTouched: [...new Set([...last.symbolsTouched, ...batch.symbolsTouched])], + evaluationsStored: last.evaluationsStored + batch.evaluationsStored, + signalsLogged: last.signalsLogged + batch.signalsLogged, + remainingDays: batch.remainingDays, + complete: batch.complete, + }; + if (batch.evaluationsStored === 0) break; + } + return { ...last, ledger: await learningLedgerStatus(db, cache) }; +} + +export async function deriveAndPersistZoneRules(db: DatabaseSync, cache: CacheRepository, rackId?: string): Promise<{ entryId: string; exitId: string; source: string; skipped?: string }> { + const repo = new ConfluenceRepository(db); + const rack = rackId + ? repo.getRack(rackId) + : repo.listSystemRacks()[0] ?? null; + if (!rack) throw new Error('confluence zone rules: no rack'); + const ledger = await learningLedgerStatus(db, cache); + if (!ledger.ready) { + return { entryId: '', exitId: '', source: 'deferred', skipped: 'learning ledger is not full enough to derive' }; + } + + const evals: EvaluationPoint[] = []; + const candlesBySymbol = new Map(); + const provider = new CacheCandleProvider(cache); + + for (const symbol of CONFLUENCE_LEARNING_UNIVERSE) { + const rows = repo.listEvaluationsForSymbolRack(symbol, rack.id); + for (const r of rows) evals.push(toPoint(r)); + const daily = await provider.resolveAsOf(symbol, '1d', '9999-12-31'); + candlesBySymbol.set(symbol, daily.candles); + } + + const derived = deriveZoneRules(evals, candlesBySymbol); + const caveat = (rule: typeof derived.entry) => + rule.source === 'baseline' + ? 'Held-out year did not beat the unconditional baseline by 10 percentage points. Showing the conservative quality-band rule.' + : `Validated on the most recent year. Train resolved windows: ${rule.train.resolved}.`; + + const entry = repo.saveActiveZoneRule({ + rackId: rack.id, + kind: 'entry', + derivedAt: new Date().toISOString(), + predicateJson: JSON.stringify(derived.entry.predicate), + trainStatsJson: JSON.stringify(derived.entry.train), + validateStatsJson: JSON.stringify(derived.entry.validate), + sampleCaveat: caveat(derived.entry), + source: derived.entry.source, + }); + const exit = repo.saveActiveZoneRule({ + rackId: rack.id, + kind: 'exit', + derivedAt: new Date().toISOString(), + predicateJson: JSON.stringify(derived.exit.predicate), + trainStatsJson: JSON.stringify(derived.exit.train), + validateStatsJson: JSON.stringify(derived.exit.validate), + sampleCaveat: caveat(derived.exit), + source: derived.exit.source, + }); + return { entryId: entry.id, exitId: exit.id, source: `${derived.entry.source}/${derived.exit.source}` }; +} + +export function replayCoverage( + db: DatabaseSync, + symbol: string, + rackId: string, + lookback = REPLAY_LOOKBACK_DAYS, + availableDays?: number, +): { evaluatedDays: number; lookbackDays: number; replayComplete: boolean } { + const repo = new ConfluenceRepository(db); + const n = repo.countEvaluations(symbol.toUpperCase(), rackId); + const target = Math.min(lookback, availableDays && availableDays > 0 ? availableDays : lookback); + return { + evaluatedDays: n, + lookbackDays: target, + replayComplete: n >= target, + }; +} + +export function loadActivePredicates(db: DatabaseSync, rackId: string): { entry: ZonePredicate; exit: ZonePredicate; entryRule: ReturnType; exitRule: ReturnType } { + const repo = new ConfluenceRepository(db); + const entryRule = repo.getActiveZoneRule(rackId, 'entry'); + const exitRule = repo.getActiveZoneRule(rackId, 'exit'); + const parse = (raw: string | undefined, fallback: ZonePredicate): ZonePredicate => { + if (!raw) return fallback; + try { return JSON.parse(raw) as ZonePredicate; } catch { return fallback; } + }; + return { + entry: parse(entryRule?.predicateJson, FALLBACK_ENTRY), + exit: parse(exitRule?.predicateJson, FALLBACK_EXIT), + entryRule, + exitRule, + }; +} + +export function scoreSymbolUnderPrior( + db: DatabaseSync, + symbol: string, + rackId: string, + candles: import('../cache/CacheRepository.ts').PriceCandle[], + kind: 'entry' | 'exit', + predicate: ZonePredicate, + opposite: ZonePredicate, +): ZoneRuleStats { + const repo = new ConfluenceRepository(db); + const evals = repo.listEvaluationsForSymbolRack(symbol.toUpperCase(), rackId).map(toPoint); + const zones = attachOutcomes(collapseZones(evals, kind, predicate, opposite, candles), candles); + return scoreZones(zones, null); +} + +export function buildRecentZones( + db: DatabaseSync, + symbol: string, + rackId: string, + candles: import('../cache/CacheRepository.ts').PriceCandle[], + entries = 3, + exits = 3, +) { + const repo = new ConfluenceRepository(db); + const evals = repo.listEvaluationsForSymbolRack(symbol.toUpperCase(), rackId).map(toPoint); + const { entry, exit } = loadActivePredicates(db, rackId); + return recentZonesForSymbol(evals, candles, entry, exit, entries, exits); } \ No newline at end of file diff --git a/app/server/src/confluence/confluenceLibrary.ts b/app/server/src/confluence/confluenceLibrary.ts index c20850c..a4db980 100644 --- a/app/server/src/confluence/confluenceLibrary.ts +++ b/app/server/src/confluence/confluenceLibrary.ts @@ -57,12 +57,13 @@ export function redundancyGroupFor(slotId: string): RedundancyGroup | undefined * `REDUNDANCY_DISCOUNT` of the previous marginal — so N same-group firings * weigh 1 + 0.5 + 0.25 + ... , never N. */ -export function groupWeight(firingSlotIds: string[]): number { +export function groupWeight(firingSlotIds: string[], weights?: ReadonlyMap): number { if (firingSlotIds.length === 0) return 0; let w = 0; let margin = 1; - for (let i = 0; i < firingSlotIds.length; i++) { - w += margin; + for (const id of firingSlotIds) { + const slotW = weights?.get(id) ?? 1; + w += margin * slotW; margin *= REDUNDANCY_DISCOUNT; } return w; diff --git a/app/server/src/confluence/confluenceRack.ts b/app/server/src/confluence/confluenceRack.ts index 48cb1b5..76ddb82 100644 --- a/app/server/src/confluence/confluenceRack.ts +++ b/app/server/src/confluence/confluenceRack.ts @@ -101,6 +101,7 @@ export function evaluateRack( symbol: string, asOf: string, assessments: SlotAssessment[], + weights?: ReadonlyMap, ): ConfluenceEvaluation { const slotById = new Map(CONFLUENCE_SLOTS.map((s) => [s.id, s])); @@ -139,9 +140,9 @@ export function evaluateRack( } let bullEvidence = 0; - for (const ids of bullGroups.values()) bullEvidence += groupWeight(ids); + for (const ids of bullGroups.values()) bullEvidence += groupWeight(ids, weights); let bearEvidence = 0; - for (const ids of bearGroups.values()) bearEvidence += groupWeight(ids); + for (const ids of bearGroups.values()) bearEvidence += groupWeight(ids, weights); const netEvidence = bullEvidence - bearEvidence; const totalEvidence = bullEvidence + bearEvidence; diff --git a/app/server/src/confluence/confluenceSeed.ts b/app/server/src/confluence/confluenceSeed.ts index 708d405..ba30db2 100644 --- a/app/server/src/confluence/confluenceSeed.ts +++ b/app/server/src/confluence/confluenceSeed.ts @@ -47,6 +47,13 @@ export const CONFLUENCE_UNIVERSE: ConfluenceSymbol[] = [ export const BENCHMARK_SYMBOL = 'SPY'; +/** 11 GICS sector ETFs + SPY. Universe-level zone rules are learned here only. */ +export const CONFLUENCE_LEARNING_UNIVERSE: string[] = [ + 'XLK', 'XLF', 'XLE', 'XLI', 'XLV', 'XLY', 'XLP', 'XLU', 'XLRE', 'XLC', 'XLB', BENCHMARK_SYMBOL, +]; + +export const REPLAY_LOOKBACK_DAYS = 756; + // --------------------------------------------------------------------------- // System rack presets // --------------------------------------------------------------------------- @@ -110,10 +117,18 @@ export async function seedConfluence( cache: CacheRepository, ): Promise<{ symbolsPinned: number; racksCreated: number }> { // 1. Pin universe symbols + benchmark into the permanent demand set. - const allSymbols: ConfluenceSymbol[] = [ - ...CONFLUENCE_UNIVERSE, - { symbol: BENCHMARK_SYMBOL, kind: 'etf', label: 'S&P 500 benchmark' }, - ]; + const learning: ConfluenceSymbol[] = CONFLUENCE_LEARNING_UNIVERSE.map((symbol) => ({ + symbol, + kind: 'etf' as TickerKind, + label: symbol, + })); + const seen = new Set(); + const allSymbols: ConfluenceSymbol[] = []; + for (const row of [...learning, ...CONFLUENCE_UNIVERSE, { symbol: BENCHMARK_SYMBOL, kind: 'etf' as TickerKind, label: 'S&P 500 benchmark' }]) { + if (seen.has(row.symbol)) continue; + seen.add(row.symbol); + allSymbols.push(row); + } let symbolsPinned = 0; for (const { symbol, kind } of allSymbols) { try { diff --git a/app/server/src/confluence/confluenceZones.ts b/app/server/src/confluence/confluenceZones.ts new file mode 100644 index 0000000..9de0e8b --- /dev/null +++ b/app/server/src/confluence/confluenceZones.ts @@ -0,0 +1,615 @@ +// Investor Flow — Confluence entry/exit zones (derived from picture follow-through) +// +// Zones are contiguous stretches of rack evaluations that match a small, named +// predicate. Predicates are scored walk-forward against 4-week price +// follow-through. The winner becomes the active rule; last-N matching windows +// are what the Confluence page shows. +// +// Pure: no I/O. ADR-0007: a zone describes a historical window, never a directive. + +import type { PriceCandle } from '../cache/CacheRepository.ts'; +import { candleIndexForDate, checkFollowThrough } from './confluenceBacktest.ts'; +import { confluenceslotById } from './confluenceSlots.ts'; +import type { PictureQuality, SlotAssessment } from './confluenceRack.ts'; + +export type ZoneKind = 'entry' | 'exit'; + +export interface EvaluationPoint { + symbol: string; + asOf: string; + quality: PictureQuality; + netEvidence: number; + bullEvidence: number; + bearEvidence: number; + assessments: SlotAssessment[]; +} + +export interface OnsetStyle { + lookback: number | null; + slide: boolean; +} + +export const ONSET_TURN_ON: OnsetStyle = { lookback: null, slide: false }; +export const ONSET_EXTREME_15_SLIDE: OnsetStyle = { lookback: 15, slide: true }; + +export const ONSET_STYLE_CATALOG: Array<{ id: string; label: string; style: OnsetStyle }> = [ + { id: 'turn-on', label: 'turn-on only', style: ONSET_TURN_ON }, + { id: 'extreme-10', label: '10-session extreme', style: { lookback: 10, slide: false } }, + { id: 'extreme-15', label: '15-session extreme', style: { lookback: 15, slide: false } }, + { id: 'extreme-15-slide', label: '15-session extreme, slide to deeper trough', style: ONSET_EXTREME_15_SLIDE }, + { id: 'extreme-20', label: '20-session extreme', style: { lookback: 20, slide: false } }, + { id: 'extreme-20-slide', label: '20-session extreme, slide to deeper trough', style: { lookback: 20, slide: true } }, +]; + +export interface ZonePredicate { + id: string; + label: string; + qualities?: PictureQuality[]; + minNetEvidence?: number; + maxNetEvidence?: number; + minBullEvidence?: number; + minBearOrExitFired?: number; + requireAnyFired?: string[]; + onsetId?: string; + onsetLookback?: number | null; + onsetSlide?: boolean; +} + +export interface ConfluenceZone { + kind: ZoneKind; + startAsOf: string; + endAsOf: string; + open: boolean; + qualityAtStart: PictureQuality; + netEvidenceAtStart: number; + firedSlotIds: string[]; + forwardReturn: number | null; + verdict: 'confirmed' | 'false_alarm' | 'open'; +} + +export interface ZoneRuleStats { + zones: number; + resolved: number; + confirmed: number; + hitRate: number | null; + avgReturn: number | null; + baselineHitRate: number | null; +} + +export interface DerivedZoneRule { + kind: ZoneKind; + predicate: ZonePredicate; + source: 'validated' | 'baseline'; + train: ZoneRuleStats; + validate: ZoneRuleStats; +} + +export const MIN_TRAIN_ZONES = 8; +export const MIN_VALIDATE_BEAT = 0.10; +export const ZONE_HORIZON_WEEKS = 4; +/** Bars painted from zone start. Matches the 4-week follow-through window. */ +export const ZONE_DISPLAY_BARS = ZONE_HORIZON_WEEKS * 5; +/** A re-entry needs this many sessions of lower/higher closes behind it. */ +export const REENTRY_LOOKBACK = 15; +/** Do not open another window inside this many evaluations of the last onset. */ +export const REENTRY_MIN_GAP = 20; + +const BULLISH: PictureQuality[] = ['weak-bullish', 'moderate-bullish', 'strong-bullish']; +const STRONG_BULL: PictureQuality[] = ['moderate-bullish', 'strong-bullish']; +const BEARISH: PictureQuality[] = ['weak-bearish', 'moderate-bearish', 'strong-bearish']; +const STRONG_BEAR: PictureQuality[] = ['moderate-bearish', 'strong-bearish']; + +export const ENTRY_TEMPLATES: ZonePredicate[] = [ + { id: 'entry-mod-strong', label: 'Moderate or strong bullish picture', qualities: STRONG_BULL }, + { id: 'entry-any-bull', label: 'Any bullish picture', qualities: BULLISH }, + { id: 'entry-net-1', label: 'Net evidence at least 1.0', minNetEvidence: 1 }, + { id: 'entry-net-2', label: 'Net evidence at least 2.0', minNetEvidence: 2 }, + { id: 'entry-net-3', label: 'Net evidence at least 3.0', minNetEvidence: 3 }, + { id: 'entry-bull-2', label: 'Bull evidence at least 2.0', minBullEvidence: 2 }, + { id: 'entry-bull-3', label: 'Bull evidence at least 3.0', minBullEvidence: 3 }, + { id: 'entry-bull-4', label: 'Bull evidence at least 4.0', minBullEvidence: 4 }, + { id: 'entry-mod-net2', label: 'Moderate or strong bullish picture and net evidence at least 2.0', qualities: STRONG_BULL, minNetEvidence: 2 }, + { id: 'entry-mod-cheap', label: 'Moderate or strong bullish picture and corridor in the cheap band', qualities: STRONG_BULL, requireAnyFired: ['corridorEntryCheap'] }, + { id: 'entry-mod-pullback', label: 'Moderate or strong bullish picture and a pullback toward EMA-21', qualities: STRONG_BULL, requireAnyFired: ['pullbackToEMA21'] }, +]; + +export const EXIT_TEMPLATES: ZonePredicate[] = [ + { id: 'exit-mod-strong', label: 'Moderate or strong bearish picture', qualities: STRONG_BEAR }, + { id: 'exit-any-bear', label: 'Any bearish picture', qualities: BEARISH }, + { id: 'exit-net-m1', label: 'Net evidence at most -1.0', maxNetEvidence: -1 }, + { id: 'exit-net-m2', label: 'Net evidence at most -2.0', maxNetEvidence: -2 }, + { id: 'exit-fired-1', label: 'At least one exit or bear slot fired', minBearOrExitFired: 1 }, + { id: 'exit-fired-2', label: 'At least two exit or bear slots fired', minBearOrExitFired: 2 }, + { id: 'exit-mod-stretch', label: 'Moderate or strong bearish picture and a stretch marker', qualities: STRONG_BEAR, requireAnyFired: ['deathCross', 'rsiOverbought', 'corridorEntryStretched'] }, +]; + +export const FALLBACK_ENTRY = ENTRY_TEMPLATES[0]; +export const FALLBACK_EXIT = EXIT_TEMPLATES[0]; + +export function matchRule(ev: EvaluationPoint, predicate: ZonePredicate): boolean { + if (predicate.qualities && !predicate.qualities.includes(ev.quality)) return false; + if (predicate.minNetEvidence != null && ev.netEvidence < predicate.minNetEvidence) return false; + if (predicate.maxNetEvidence != null && ev.netEvidence > predicate.maxNetEvidence) return false; + if (predicate.minBullEvidence != null && ev.bullEvidence < predicate.minBullEvidence) return false; + if (predicate.minBearOrExitFired != null) { + const n = ev.assessments.filter((a) => { + if (a.state !== 'fired') return false; + const body = confluenceslotById.get(a.id)?.body; + return body === 'bear' || body === 'exit'; + }).length; + if (n < predicate.minBearOrExitFired) return false; + } + if (predicate.requireAnyFired && predicate.requireAnyFired.length > 0) { + const fired = new Set(ev.assessments.filter((a) => a.state === 'fired').map((a) => a.id)); + if (!predicate.requireAnyFired.some((id) => fired.has(id))) return false; + } + return true; +} + +export function predicateComplexity(predicate: ZonePredicate): number { + let n = 0; + if (predicate.qualities) n += 1; + if (predicate.minNetEvidence != null) n += 1; + if (predicate.maxNetEvidence != null) n += 1; + if (predicate.minBullEvidence != null) n += 1; + if (predicate.minBearOrExitFired != null) n += 1; + if (predicate.requireAnyFired) n += 1; + if (predicate.onsetLookback != null) n += 1; + if (predicate.onsetSlide) n += 1; + return n; +} + +export function onsetStyleOf(predicate: ZonePredicate): OnsetStyle { + if (predicate.onsetLookback === undefined && predicate.onsetSlide === undefined) { + return ONSET_TURN_ON; + } + return { + lookback: predicate.onsetLookback ?? null, + slide: predicate.onsetSlide === true, + }; +} + +export function expandTemplates(pictures: ZonePredicate[]): ZonePredicate[] { + const out: ZonePredicate[] = []; + for (const p of pictures) { + for (const o of ONSET_STYLE_CATALOG) { + out.push({ + ...p, + id: `${p.id}+${o.id}`, + label: o.id === 'turn-on' ? p.label : `${p.label}; ${o.label}`, + onsetId: o.id, + onsetLookback: o.style.lookback, + onsetSlide: o.style.slide, + }); + } + } + return out; +} + +/** True when `asOf` is the lowest (entry) or highest (exit) close in the lookback. */ +export function isLookbackExtreme( + candles: PriceCandle[], + asOf: string, + kind: ZoneKind, + lookback = REENTRY_LOOKBACK, +): boolean { + const idx = candleIndexForDate(candles, asOf.slice(0, 10)); + if (idx < 0 || lookback <= 0) return false; + const start = Math.max(0, idx - lookback + 1); + const px = candles[idx].c; + if (!Number.isFinite(px)) return false; + for (let i = start; i <= idx; i++) { + const c = candles[i].c; + if (!Number.isFinite(c)) continue; + if (kind === 'entry' && c < px) return false; + if (kind === 'exit' && c > px) return false; + } + return true; +} + +/** Lookback extreme that has started to reverse (next close turns the other way). Last bar is allowed without confirmation. */ +export function isConfirmedExtreme( + candles: PriceCandle[], + asOf: string, + kind: ZoneKind, + lookback = REENTRY_LOOKBACK, +): boolean { + if (!isLookbackExtreme(candles, asOf, kind, lookback)) return false; + const idx = candleIndexForDate(candles, asOf.slice(0, 10)); + if (idx < 0 || idx + 1 >= candles.length) return true; + const px = candles[idx].c; + const next = candles[idx + 1].c; + if (!Number.isFinite(next)) return true; + return kind === 'entry' ? next > px : next < px; +} + +function closeOn(candles: PriceCandle[], asOf: string): number | null { + const idx = candleIndexForDate(candles, asOf.slice(0, 10)); + if (idx < 0) return null; + const px = candles[idx].c; + return Number.isFinite(px) ? px : null; +} + +/** + * Onsets: the picture turns on, or it stays on and price prints a fresh + * N-session extreme after a quiet gap. That second case is a pullback + * (entry) or a bounce into strength (exit) inside the same picture. + */ +export function findOnsets( + evals: EvaluationPoint[], + kind: ZoneKind, + predicate: ZonePredicate, + opposite?: ZonePredicate, + candles?: PriceCandle[], + style: OnsetStyle = onsetStyleOf(predicate), +): string[] { + const ordered = [...evals].sort((a, b) => a.asOf.localeCompare(b.asOf)); + const onsets: string[] = []; + let prevMatch = false; + let lastOnsetEvalIdx = -REENTRY_MIN_GAP; + const lookback = style.lookback; + + for (let i = 0; i < ordered.length; i++) { + const ev = ordered[i]; + const matches = matchRule(ev, predicate); + const flipped = opposite ? matchRule(ev, opposite) : false; + if (flipped || !matches) { + prevMatch = false; + continue; + } + const fresh = !prevMatch; + if (lookback == null || !candles) { + if (fresh) { + onsets.push(ev.asOf); + lastOnsetEvalIdx = i; + } + prevMatch = true; + continue; + } + const gapOk = i - lastOnsetEvalIdx >= REENTRY_MIN_GAP; + const confirmed = isConfirmedExtreme(candles, ev.asOf, kind, lookback); + const pullback = confirmed && gapOk; + if (fresh || pullback) { + onsets.push(ev.asOf); + lastOnsetEvalIdx = i; + } else if (style.slide && confirmed && onsets.length > 0) { + const lastPx = closeOn(candles, onsets[onsets.length - 1]); + const px = closeOn(candles, ev.asOf); + const deeper = lastPx != null && px != null && ( + kind === 'entry' ? px < lastPx : px > lastPx + ); + if (deeper && i - lastOnsetEvalIdx < REENTRY_MIN_GAP) { + onsets[onsets.length - 1] = ev.asOf; + lastOnsetEvalIdx = i; + } + } + prevMatch = true; + } + return onsets; +} + +function zoneFromOnset( + ordered: EvaluationPoint[], + startAsOf: string, + kind: ZoneKind, + candles: PriceCandle[] | undefined, + isLast: boolean, +): ConfluenceZone | null { + const start = ordered.find((e) => e.asOf === startAsOf); + if (!start) return null; + let endAsOf = startAsOf; + if (candles) { + const win = zoneOnsetWindow(startAsOf, '9999-12-31', candles); + if (win) endAsOf = win.endAsOf; + } + const last = ordered[ordered.length - 1]; + return { + kind, + startAsOf, + endAsOf, + open: isLast && last != null && last.asOf >= startAsOf, + qualityAtStart: start.quality, + netEvidenceAtStart: start.netEvidence, + firedSlotIds: start.assessments.filter((a) => a.state === 'fired').map((a) => a.id), + forwardReturn: null, + verdict: 'open', + }; +} + +/** + * Collapse a chronological evaluation series into contiguous zones. + * A single off-rule session does not split a zone. Two off-rule sessions, or + * a day that matches the opposite predicate, closes it. + * + * When `candles` are passed, long stretches are split on N-session pullbacks + * (entry) or bounce highs (exit) so a year-long bullish picture is not one zone. + */ +export function collapseZones( + evals: EvaluationPoint[], + kind: ZoneKind, + predicate: ZonePredicate, + opposite?: ZonePredicate, + candles?: PriceCandle[], +): ConfluenceZone[] { + const ordered = [...evals].sort((a, b) => a.asOf.localeCompare(b.asOf)); + if (ordered.length === 0) return []; + + if (candles && candles.length > 0) { + const onsets = findOnsets(ordered, kind, predicate, opposite, candles, onsetStyleOf(predicate)); + return onsets + .map((asOf, i) => zoneFromOnset(ordered, asOf, kind, candles, i === onsets.length - 1)) + .filter((z): z is ConfluenceZone => z != null); + } + + const zones: ConfluenceZone[] = []; + let startIdx = -1; + let lastMatchIdx = -1; + + const close = (endIdx: number) => { + if (startIdx < 0 || lastMatchIdx < 0) return; + const start = ordered[startIdx]; + const end = ordered[lastMatchIdx]; + const open = lastMatchIdx >= ordered.length - 2; + zones.push({ + kind, + startAsOf: start.asOf, + endAsOf: end.asOf, + open, + qualityAtStart: start.quality, + netEvidenceAtStart: start.netEvidence, + firedSlotIds: start.assessments.filter((a) => a.state === 'fired').map((a) => a.id), + forwardReturn: null, + verdict: 'open', + }); + startIdx = -1; + lastMatchIdx = -1; + }; + + for (let i = 0; i < ordered.length; i++) { + const ev = ordered[i]; + const matches = matchRule(ev, predicate); + const flipped = opposite ? matchRule(ev, opposite) : false; + + if (flipped && startIdx >= 0) { + close(i - 1); + } + + if (matches) { + if (startIdx < 0) startIdx = i; + lastMatchIdx = i; + } else if (startIdx >= 0 && i - lastMatchIdx > 1) { + close(i); + } + } + if (startIdx >= 0) close(ordered.length - 1); + return zones; +} + +export function lastNZones(zones: ConfluenceZone[], n: number): ConfluenceZone[] { + if (n <= 0) return []; + return zones.slice(-n); +} + +/** + * The chart marks the onset of a zone (the first `bars` sessions), not the + * whole stretch the picture stayed on. A 14-month open bullish picture is + * still one onset in June, not a year of green. + */ +export function zoneOnsetWindow( + startAsOf: string, + endAsOf: string, + candles: PriceCandle[], + bars = ZONE_DISPLAY_BARS, +): { startAsOf: string; endAsOf: string } | null { + const startIdx = candleIndexForDate(candles, startAsOf.slice(0, 10)); + if (startIdx < 0 || bars <= 0) return null; + const naturalEndIdx = candleIndexForDate(candles, endAsOf.slice(0, 10)); + const capIdx = startIdx + bars - 1; + const endIdx = naturalEndIdx >= 0 + ? Math.min(naturalEndIdx, capIdx, candles.length - 1) + : Math.min(capIdx, candles.length - 1); + if (endIdx < startIdx) return null; + return { + startAsOf: (candles[startIdx].ts ?? startAsOf).slice(0, 10), + endAsOf: (candles[endIdx].ts ?? endAsOf).slice(0, 10), + }; +} + +export function attachOutcomes( + zones: ConfluenceZone[], + candles: PriceCandle[], + weeks = ZONE_HORIZON_WEEKS, +): ConfluenceZone[] { + return zones.map((z) => { + const direction = z.kind === 'entry' ? 'up' : 'down'; + const idx = candleIndexForDate(candles, z.startAsOf); + const ret = forwardReturnFrom(candles, z.startAsOf, weeks); + if (idx < 0 || ret === null) { + return { ...z, forwardReturn: null, verdict: 'open' as const }; + } + const confirmed = checkFollowThrough(candles, idx, direction, weeks); + if (confirmed === null) { + return { ...z, forwardReturn: ret, verdict: 'open' as const }; + } + return { + ...z, + forwardReturn: ret, + verdict: confirmed ? 'confirmed' : 'false_alarm', + }; + }); +} + +export function forwardReturnFrom(candles: PriceCandle[], asOf: string, weeks = ZONE_HORIZON_WEEKS): number | null { + const idx = candleIndexForDate(candles, asOf); + if (idx < 0) return null; + const target = idx + weeks * 5; + if (target >= candles.length) return null; + const entry = candles[idx].c; + const exit = candles[target].c; + if (!Number.isFinite(entry) || !Number.isFinite(exit) || entry <= 0) return null; + return (exit - entry) / entry; +} + +export function emptyStats(): ZoneRuleStats { + return { zones: 0, resolved: 0, confirmed: 0, hitRate: null, avgReturn: null, baselineHitRate: null }; +} + +export function scoreZones(zones: ConfluenceZone[], baselineHitRate: number | null): ZoneRuleStats { + const resolved = zones.filter((z) => z.verdict !== 'open'); + const confirmed = resolved.filter((z) => z.verdict === 'confirmed'); + const rets = resolved.map((z) => z.forwardReturn).filter((v): v is number => v != null); + const avgReturn = rets.length > 0 ? rets.reduce((a, b) => a + b, 0) / rets.length : null; + return { + zones: zones.length, + resolved: resolved.length, + confirmed: confirmed.length, + hitRate: resolved.length > 0 ? confirmed.length / resolved.length : null, + avgReturn, + baselineHitRate, + }; +} + +export function baselineHitRate( + evals: EvaluationPoint[], + candlesBySymbol: Map, + kind: ZoneKind, + weeks = ZONE_HORIZON_WEEKS, +): number | null { + let resolved = 0; + let confirmed = 0; + const direction = kind === 'entry' ? 'up' : 'down'; + for (const ev of evals) { + const candles = candlesBySymbol.get(ev.symbol); + if (!candles) continue; + const idx = candleIndexForDate(candles, ev.asOf); + if (idx < 0) continue; + const hit = checkFollowThrough(candles, idx, direction, weeks); + if (hit === null) continue; + resolved += 1; + if (hit) confirmed += 1; + } + return resolved > 0 ? confirmed / resolved : null; +} + +export function splitTrainValidate(evals: EvaluationPoint[], trainEnd: string): { train: EvaluationPoint[]; validate: EvaluationPoint[] } { + const ordered = [...evals].sort((a, b) => a.asOf.localeCompare(b.asOf)); + return { + train: ordered.filter((e) => e.asOf <= trainEnd), + validate: ordered.filter((e) => e.asOf > trainEnd), + }; +} + +/** Default train cut: last year of the series is held out. */ +export function defaultTrainEnd(evals: EvaluationPoint[]): string | null { + const dates = [...new Set(evals.map((e) => e.asOf))].sort(); + if (dates.length === 0) return null; + const last = dates[dates.length - 1]; + const cut = new Date(`${last}T00:00:00Z`); + cut.setUTCFullYear(cut.getUTCFullYear() - 1); + return cut.toISOString().slice(0, 10); +} + +function scoreTemplate( + kind: ZoneKind, + predicate: ZonePredicate, + opposite: ZonePredicate | undefined, + train: EvaluationPoint[], + validate: EvaluationPoint[], + candlesBySymbol: Map, + trainBaseline: number | null, + validateBaseline: number | null, +): { train: ZoneRuleStats; validate: ZoneRuleStats } { + const scoreSplit = (rows: EvaluationPoint[], baseline: number | null): ZoneRuleStats => { + const bySymbol = new Map(); + for (const ev of rows) { + const arr = bySymbol.get(ev.symbol) ?? []; + arr.push(ev); + bySymbol.set(ev.symbol, arr); + } + const all: ConfluenceZone[] = []; + for (const [symbol, series] of bySymbol) { + const candles = candlesBySymbol.get(symbol) ?? []; + all.push(...attachOutcomes(collapseZones(series, kind, predicate, opposite, candles), candles)); + } + return scoreZones(all, baseline); + }; + return { + train: scoreSplit(train, trainBaseline), + validate: scoreSplit(validate, validateBaseline), + }; +} + +export function selectWinner( + kind: ZoneKind, + templates: ZonePredicate[], + evals: EvaluationPoint[], + candlesBySymbol: Map, + fallback: ZonePredicate, + oppositeFallback?: ZonePredicate, +): DerivedZoneRule { + const cut = defaultTrainEnd(evals); + if (!cut || evals.length === 0) { + return { + kind, + predicate: fallback, + source: 'baseline', + train: emptyStats(), + validate: emptyStats(), + }; + } + const { train, validate } = splitTrainValidate(evals, cut); + const trainBaseline = baselineHitRate(train, candlesBySymbol, kind); + const validateBaseline = baselineHitRate(validate, candlesBySymbol, kind); + + type Cand = { predicate: ZonePredicate; train: ZoneRuleStats; validate: ZoneRuleStats }; + const survivors: Cand[] = []; + + for (const predicate of templates) { + const scored = scoreTemplate(kind, predicate, oppositeFallback, train, validate, candlesBySymbol, trainBaseline, validateBaseline); + if (scored.train.resolved < MIN_TRAIN_ZONES) continue; + if (scored.validate.hitRate == null || validateBaseline == null) continue; + if (scored.validate.hitRate < validateBaseline + MIN_VALIDATE_BEAT) continue; + survivors.push({ predicate, train: scored.train, validate: scored.validate }); + } + + survivors.sort((a, b) => { + const hit = (b.validate.hitRate ?? 0) - (a.validate.hitRate ?? 0); + if (Math.abs(hit) > 1e-9) return hit; + const simpler = predicateComplexity(a.predicate) - predicateComplexity(b.predicate); + if (simpler !== 0) return simpler; + return b.train.resolved - a.train.resolved; + }); + + if (survivors.length === 0) { + const scored = scoreTemplate(kind, fallback, oppositeFallback, train, validate, candlesBySymbol, trainBaseline, validateBaseline); + return { kind, predicate: fallback, source: 'baseline', train: scored.train, validate: scored.validate }; + } + + const win = survivors[0]; + return { kind, predicate: win.predicate, source: 'validated', train: win.train, validate: win.validate }; +} + +export function deriveZoneRules( + evals: EvaluationPoint[], + candlesBySymbol: Map, +): { entry: DerivedZoneRule; exit: DerivedZoneRule } { + const entry = selectWinner('entry', expandTemplates(ENTRY_TEMPLATES), evals, candlesBySymbol, FALLBACK_ENTRY, FALLBACK_EXIT); + const exit = selectWinner('exit', expandTemplates(EXIT_TEMPLATES), evals, candlesBySymbol, FALLBACK_EXIT, FALLBACK_ENTRY); + return { entry, exit }; +} + +export function recentZonesForSymbol( + evals: EvaluationPoint[], + candles: PriceCandle[], + entryRule: ZonePredicate, + exitRule: ZonePredicate, + entries = 3, + exits = 3, +): { entries: ConfluenceZone[]; exits: ConfluenceZone[] } { + const entryZones = attachOutcomes(collapseZones(evals, 'entry', entryRule, exitRule, candles), candles); + const exitZones = attachOutcomes(collapseZones(evals, 'exit', exitRule, entryRule, candles), candles); + return { + entries: lastNZones(entryZones, entries), + exits: lastNZones(exitZones, exits), + }; +} diff --git a/app/server/src/db/__tests__/fund13fSync.test.ts b/app/server/src/db/__tests__/fund13fSync.test.ts index 21f15d9..41444af 100644 --- a/app/server/src/db/__tests__/fund13fSync.test.ts +++ b/app/server/src/db/__tests__/fund13fSync.test.ts @@ -6,7 +6,7 @@ import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { upsertTrackedFund, sync13FIntoRecords, liveBook, quarterEnd } from '../fundRepository.ts'; +import { upsertTrackedFund, sync13FIntoRecords, liveBook, quarterEnd, insertPositionRecord } from '../fundRepository.ts'; const __dirname = dirname(fileURLToPath(import.meta.url)); const SCHEMA_SQL = readFileSync(join(__dirname, '..', 'schema.sql'), 'utf8'); @@ -41,4 +41,86 @@ test('quarterEnd helper', () => { assert.equal(quarterEnd('2025-Q4'), '2025-12-31'); assert.equal(quarterEnd('2026-Q2'), '2026-06-30'); assert.equal(quarterEnd('2026-Q1'), '2026-03-31'); +}); + +test('sync13FIntoRecords: missing from latest quarter is an exit (shares=0)', () => { + const db = freshDb(); + const fund = upsertTrackedFund(db, { ci_key: '0002096493', fund_name: 'Alpine Fox Capital', manager_name: 'M' }); + db.prepare( + `INSERT INTO institution_filings (filer_cik, filer_name, symbol, form, shares, value_usd, reported_quarter, filed_at, fetched_at, accession) + VALUES ('0002096493','AFC','IREN','13F-HR',750000,30000000,'2026-Q1','2026-05-14','2026-05-14','A1'), + ('0002096493','AFC','CIFR','13F-HR',100000,1287000,'2026-Q1','2026-05-14','2026-05-14','A1'), + ('0002096493','AFC','IREN','13F-HR',750000,34000000,'2026-Q2','2026-08-14','2026-08-14','A2')`, + ).run(); + + const n = sync13FIntoRecords(db, fund.id); + assert.ok(n >= 3, 'IREN Q1+Q2, CIFR Q1, plus CIFR exit'); + assert.equal(sync13FIntoRecords(db, fund.id), 0); + + const book = liveBook(db, fund.id); + const iren = book.find((p) => p.symbol === 'IREN')!; + const cifr = book.find((p) => p.symbol === 'CIFR')!; + assert.equal(iren.status, 'held'); + assert.equal(iren.shares, 750000); + assert.equal(iren.as_of, '2026-06-30'); + assert.equal(cifr.status, 'exited'); + assert.equal(cifr.shares, 0); + assert.equal(cifr.as_of, '2026-06-30'); + assert.equal(cifr.last_held_shares, 100000); + assert.equal(cifr.source, '13f'); +}); + +test('sync13FIntoRecords: later capture after 13F exit is held again', () => { + const db = freshDb(); + const fund = upsertTrackedFund(db, { ci_key: '0002096493', fund_name: 'Alpine Fox Capital', manager_name: 'M' }); + db.prepare( + `INSERT INTO institution_filings (filer_cik, filer_name, symbol, form, shares, value_usd, reported_quarter, filed_at, fetched_at, accession) + VALUES ('0002096493','AFC','DEO','13F-HR',122000,10000000,'2026-Q1','2026-05-14','2026-05-14','A1'), + ('0002096493','AFC','IREN','13F-HR',750000,34000000,'2026-Q2','2026-08-14','2026-08-14','A2')`, + ).run(); + sync13FIntoRecords(db, fund.id); + assert.equal(liveBook(db, fund.id).find((p) => p.symbol === 'DEO')!.status, 'exited'); + + insertPositionRecord(db, { + fund_id: fund.id, symbol: 'DEO', shares: 50000, as_of: '2026-07-15', source: 'capture', + }); + const deo = liveBook(db, fund.id).find((p) => p.symbol === 'DEO')!; + assert.equal(deo.status, 'held'); + assert.equal(deo.shares, 50000); + assert.equal(deo.source, 'capture'); +}); + +test('sync13FIntoRecords: put_call Call is a call line; share change vs prior quarter', () => { + const db = freshDb(); + const fund = upsertTrackedFund(db, { ci_key: '0002096493', fund_name: 'Alpine Fox Capital', manager_name: 'M' }); + db.prepare( + `INSERT INTO institution_filings (filer_cik, filer_name, symbol, form, shares, value_usd, reported_quarter, filed_at, fetched_at, accession, put_call) + VALUES ('0002096493','AFC','IBIT','13F-HR',1172200,45000000,'2026-Q1','2026-05-14','2026-05-14','A1','Call'), + ('0002096493','AFC','IBIT','13F-HR',1120000,37000000,'2026-Q2','2026-08-14','2026-08-14','A2','Call'), + ('0002096493','AFC','IREN','13F-HR',750461,34000000,'2026-Q2','2026-08-14','2026-08-14','A2','')`, + ).run(); + sync13FIntoRecords(db, fund.id); + const ibit = liveBook(db, fund.id).find((p) => p.symbol === 'IBIT')!; + assert.equal(ibit.notes, 'call'); + assert.equal(ibit.status, 'held'); + assert.equal(ibit.shares, 1120000); + assert.equal(ibit.prior_shares, 1172200); + assert.equal(ibit.change, 'cut'); + assert.equal(ibit.share_delta, -52200); + const iren = liveBook(db, fund.id).find((p) => p.symbol === 'IREN')!; + assert.equal(iren.notes, null); + assert.equal(iren.change, 'new'); +}); + +test('sync13FIntoRecords: empty latest quarter does not mass-exit', () => { + const db = freshDb(); + const fund = upsertTrackedFund(db, { ci_key: '0002096493', fund_name: 'Alpine Fox Capital', manager_name: 'M' }); + db.prepare( + `INSERT INTO institution_filings (filer_cik, filer_name, symbol, form, shares, value_usd, reported_quarter, filed_at, fetched_at, accession) + VALUES ('0002096493','AFC','IREN','13F-HR',750000,30000000,'2026-Q1','2026-05-14','2026-05-14','A1')`, + ).run(); + sync13FIntoRecords(db, fund.id); + const book = liveBook(db, fund.id); + assert.equal(book.length, 1); + assert.equal(book[0].status, 'held'); }); \ No newline at end of file diff --git a/app/server/src/db/__tests__/fundRepository.test.ts b/app/server/src/db/__tests__/fundRepository.test.ts index 565cfa8..ed706d7 100644 --- a/app/server/src/db/__tests__/fundRepository.test.ts +++ b/app/server/src/db/__tests__/fundRepository.test.ts @@ -10,6 +10,7 @@ import { listTrackedFunds, getTrackedFund, upsertTrackedFund, setFundEnabled, deleteTrackedFund, insertPositionRecord, liveBook, listRecords, + fundFreshness, } from '../fundRepository.ts'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -52,6 +53,23 @@ test('registry: upsert creates and is idempotent by CIK, then lists enabled firs assert.equal(got!.enabled, false); }); +test('freshness: queued X handle with no posts is pending / never posted', () => { + const db = freshDb(); + const fund = upsertTrackedFund(db, { + ci_key: '000111', fund_name: 'The Prof Investor', manager_name: 'Prof', + x_handle: 'TheProfInvestor', + }); + db.prepare( + `INSERT INTO adapter_queue (key, status, last_attempt, retry_count) VALUES (?, 'pending', NULL, 0)`, + ).run('x:timeline:TheProfInvestor'); + const fresh = fundFreshness(db, getTrackedFund(db, fund.id)!); + assert.equal(fresh.xHandle, 'TheProfInvestor'); + assert.equal(fresh.xPull.status, 'pending'); + assert.equal(fresh.lastPostAt, null); + assert.equal(fresh.lastCaptureAt, null); + assert.equal(fresh.last13fAt, null); +}); + test('registry: enable/disable toggle and delete', () => { const db = freshDb(); const id = seedFund(db); @@ -85,6 +103,22 @@ test('liveBook: most recent record per (fund, symbol) by as_of, any source', () assert.equal(book2[0].shares, 950000); }); +test('liveBook: capture with no 13F for that instrument is x_only', () => { + const db = freshDb(); + const fund = seedFund(db); + insertPositionRecord(db, { fund_id: fund, symbol: 'ANY', shares: 200000, as_of: '2026-07-28', source: 'capture' }); + insertPositionRecord(db, { fund_id: fund, symbol: 'OPEN', shares: 1670199, as_of: '2026-03-31', source: '13f' }); + insertPositionRecord(db, { fund_id: fund, symbol: 'OPEN', shares: 7050000, as_of: '2026-08-11', source: 'capture' }); + insertPositionRecord(db, { fund_id: fund, symbol: 'WULF', shares: 100000, as_of: '2025-12-31', source: '13f' }); + insertPositionRecord(db, { fund_id: fund, symbol: 'WULF', as_of: '2026-07-30', source: 'capture', notes: 'call' }); + const book = liveBook(db, fund); + const by = Object.fromEntries(book.map((p) => [`${p.symbol}:${p.notes ?? 'eq'}`, p])); + assert.equal(by['ANY:eq'].x_only, true); + assert.equal(by['OPEN:eq'].x_only, false); + assert.equal(by['OPEN:eq'].source, 'capture'); + assert.equal(by['WULF:call'].x_only, true); +}); + test('live: multiple symbols independent recency', () => { const db = freshDb(); const fund = seedFund(db); diff --git a/app/server/src/db/__tests__/schema.test.ts b/app/server/src/db/__tests__/schema.test.ts index 9b84970..dfde6d2 100644 --- a/app/server/src/db/__tests__/schema.test.ts +++ b/app/server/src/db/__tests__/schema.test.ts @@ -19,7 +19,7 @@ const EXPECTED_TABLES = [ 'screener_filters', 'reports', 'alerts', 'trusted_accounts', 'saved_posts', 'trade_key_levels', 'llm_summaries', 'rotation_signals', 'rotation_rank_snapshots', 'llm_providers', 'llm_dispatch_audit', - 'confluence_racks', 'confluence_evaluations', 'confluence_signal_history', + 'confluence_racks', 'confluence_evaluations', 'confluence_signal_history', 'confluence_zone_rules', ]; test('schema creates all locked tables', () => { diff --git a/app/server/src/db/alertSubscriptionRepository.ts b/app/server/src/db/alertSubscriptionRepository.ts index 540f39e..dd7f6d5 100644 --- a/app/server/src/db/alertSubscriptionRepository.ts +++ b/app/server/src/db/alertSubscriptionRepository.ts @@ -35,7 +35,7 @@ export const ALERT_TYPE_CATALOG: Array<{ type: string; label: string; descriptio { type: 'asymmetry_warning', label: 'Asymmetry Warning', description: 'Portfolio reward-to-risk below threshold.' }, { type: 'fund_capture', label: 'Fund Capture', description: 'A tracked fund posted a position update.' }, { type: 'fund_13f', label: 'Tracked Fund 13F', description: 'A tracked fund filed a new 13F.' }, - { type: 'mirror_diff', label: 'Mirror Diff', description: 'The mirror target book changed materially.' }, + { type: 'mirror_diff', label: 'Tracked Fund Book', description: "A tracked fund's disclosed book changed materially." }, { type: 'vix_level', label: 'VIX Level', description: 'VIX moves into a new historical volatility band.' }, { type: 'confluence_change', label: 'Confluence Picture Change', description: 'A symbol\'s confluence picture improved or became more cautious.' }, ]; diff --git a/app/server/src/db/client.ts b/app/server/src/db/client.ts index a1c0251..118547d 100644 --- a/app/server/src/db/client.ts +++ b/app/server/src/db/client.ts @@ -88,6 +88,7 @@ function runMigrations(db: DatabaseSync): void { // Extended-hours quote fields (pre/post last vs RTH close) `ALTER TABLE quotes ADD COLUMN session TEXT`, `ALTER TABLE quotes ADD COLUMN regular_price REAL`, + `ALTER TABLE x_cookie_posts ADD COLUMN quoted_post_id TEXT`, `DROP INDEX IF EXISTS idx_watchlists_owner_name`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_watchlists_owner_kind_name ON watchlists(owner_id, kind, name)`, `CREATE TABLE IF NOT EXISTS rotation_state ( @@ -328,7 +329,13 @@ function runMigrations(db: DatabaseSync): void { // 3. Idempotency index for institution_filings (now safe after de-dupe). try { - db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS uq_inst_filings ON institution_filings(filer_cik, symbol, reported_quarter, form)`); + db.exec(`UPDATE institution_filings SET put_call = '' WHERE put_call IS NULL`); + } catch { /* ignore */ } + try { + db.exec(`DROP INDEX IF EXISTS uq_inst_filings`); + } catch { /* ignore */ } + try { + db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS uq_inst_filings ON institution_filings(filer_cik, symbol, reported_quarter, form, put_call)`); } catch { /* ignore */ } // 4. Rename legacy 'Starter' watchlist to 'default'. @@ -670,6 +677,36 @@ function runMigrations(db: DatabaseSync): void { db.exec(`CREATE INDEX IF NOT EXISTS idx_mentor_calls_owner ON mentor_calls(owner_id, logged_at DESC)`); db.exec(`CREATE INDEX IF NOT EXISTS idx_mentor_calls_handle ON mentor_calls(owner_id, handle)`); } catch { /* ignore */ } + + // Confluence zone rules + unique signal-fire key (replay must not double-log). + try { + db.exec(` + CREATE TABLE IF NOT EXISTS confluence_zone_rules ( + id TEXT PRIMARY KEY, + rack_id TEXT NOT NULL REFERENCES confluence_racks(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + version INTEGER NOT NULL, + derived_at TEXT NOT NULL, + predicate_json TEXT NOT NULL, + train_stats_json TEXT NOT NULL, + validate_stats_json TEXT NOT NULL, + sample_caveat TEXT NOT NULL, + source TEXT NOT NULL, + is_active INTEGER NOT NULL DEFAULT 0 + ) + `); + db.exec(`CREATE INDEX IF NOT EXISTS idx_confluence_zone_rules_rack ON confluence_zone_rules(rack_id, kind, is_active)`); + } catch { /* ignore */ } + try { + db.exec(` + DELETE FROM confluence_signal_history + WHERE rowid NOT IN ( + SELECT MIN(rowid) FROM confluence_signal_history + GROUP BY symbol, slot_id, rack_id, fired_at + ) + `); + db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS uq_confluence_signal_fire ON confluence_signal_history(symbol, slot_id, rack_id, fired_at)`); + } catch { /* ignore */ } } let _db: DatabaseSync | null = null; diff --git a/app/server/src/db/confluenceRepository.ts b/app/server/src/db/confluenceRepository.ts index 83f779f..63b002c 100644 --- a/app/server/src/db/confluenceRepository.ts +++ b/app/server/src/db/confluenceRepository.ts @@ -47,6 +47,21 @@ export interface StoredConfluenceEvaluation { createdAt: string; } +/** Persisted walk-forward zone rule. */ +export interface StoredZoneRule { + id: string; + rackId: string; + kind: 'entry' | 'exit'; + version: number; + derivedAt: string; + predicateJson: string; + trainStatsJson: string; + validateStatsJson: string; + sampleCaveat: string; + source: 'validated' | 'baseline'; + isActive: boolean; +} + /** A slot-fire logged from an evaluation, awaiting verdict resolution. */ export interface ConfluenceSignalHistoryRow { id: string; @@ -133,10 +148,40 @@ function stmts(db: DatabaseSync) { // --- confluence_signal_history --- insertSignal: db.prepare( - `INSERT INTO confluence_signal_history + `INSERT OR IGNORE INTO confluence_signal_history (id, symbol, slot_id, rack_id, fired_at, quality_at_fire, price_resolved, price_confirmed, resolved_at, verdict) VALUES (?, ?, ?, ?, ?, ?, 0, 0, NULL, NULL)`, ), + countEvaluationsForSymbolRack: db.prepare( + `SELECT COUNT(*) AS n FROM confluence_evaluations WHERE symbol = ? AND rack_id = ?`, + ), + listEvaluationAsOfs: db.prepare( + `SELECT as_of FROM confluence_evaluations WHERE symbol = ? AND rack_id = ? ORDER BY as_of ASC`, + ), + selectEvaluationsForSymbolRack: db.prepare( + `SELECT id, symbol, as_of, rack_id, assessments_json, bull_evidence, bear_evidence, + bull_count, bear_count, assessed_count, net_evidence, total_evidence, quality, created_at + FROM confluence_evaluations WHERE symbol = ? AND rack_id = ? ORDER BY as_of ASC`, + ), + insertZoneRule: db.prepare( + `INSERT INTO confluence_zone_rules + (id, rack_id, kind, version, derived_at, predicate_json, train_stats_json, + validate_stats_json, sample_caveat, source, is_active) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ), + deactivateZoneRules: db.prepare( + `UPDATE confluence_zone_rules SET is_active = 0 WHERE rack_id = ? AND kind = ?`, + ), + selectActiveZoneRule: db.prepare( + `SELECT id, rack_id, kind, version, derived_at, predicate_json, train_stats_json, + validate_stats_json, sample_caveat, source, is_active + FROM confluence_zone_rules + WHERE rack_id = ? AND kind = ? AND is_active = 1 + ORDER BY version DESC LIMIT 1`, + ), + maxZoneRuleVersion: db.prepare( + `SELECT COALESCE(MAX(version), 0) AS v FROM confluence_zone_rules WHERE rack_id = ? AND kind = ?`, + ), selectPendingSignals: db.prepare( `SELECT id, symbol, slot_id, rack_id, fired_at, quality_at_fire, price_resolved, price_confirmed, resolved_at, verdict FROM confluence_signal_history WHERE price_resolved = 0 ORDER BY fired_at`, @@ -339,6 +384,61 @@ export class ConfluenceRepository { resolveSignal(id: string, confirmed: boolean, verdict: 'real' | 'false_alarm'): void { stmts(this.db).resolveSignal.run(confirmed ? 1 : 0, new Date().toISOString(), verdict, id); } + + countEvaluations(symbol: string, rackId: string): number { + const row = stmts(this.db).countEvaluationsForSymbolRack.get(symbol, rackId) as { n: number } | undefined; + return Number(row?.n ?? 0); + } + + listEvaluationAsOfs(symbol: string, rackId: string): string[] { + return (stmts(this.db).listEvaluationAsOfs.all(symbol, rackId) as Array<{ as_of: string }>).map((r) => r.as_of); + } + + listEvaluationsForSymbolRack(symbol: string, rackId: string): StoredConfluenceEvaluation[] { + return (stmts(this.db).selectEvaluationsForSymbolRack.all(symbol, rackId) as Record[]).map(mapEvaluation); + } + + getActiveZoneRule(rackId: string, kind: 'entry' | 'exit'): StoredZoneRule | null { + const row = stmts(this.db).selectActiveZoneRule.get(rackId, kind) as Record | undefined; + return row ? mapZoneRule(row) : null; + } + + saveActiveZoneRule(rule: Omit): StoredZoneRule { + const s = stmts(this.db); + const version = Number((s.maxZoneRuleVersion.get(rule.rackId, rule.kind) as { v: number }).v) + 1; + const id = randomUUID(); + s.deactivateZoneRules.run(rule.rackId, rule.kind); + s.insertZoneRule.run( + id, + rule.rackId, + rule.kind, + version, + rule.derivedAt, + rule.predicateJson, + rule.trainStatsJson, + rule.validateStatsJson, + rule.sampleCaveat, + rule.source, + 1, + ); + return { ...rule, id, version, isActive: true }; + } +} + +function mapZoneRule(row: Record): StoredZoneRule { + return { + id: row.id as string, + rackId: row.rack_id as string, + kind: row.kind as 'entry' | 'exit', + version: Number(row.version), + derivedAt: row.derived_at as string, + predicateJson: row.predicate_json as string, + trainStatsJson: row.train_stats_json as string, + validateStatsJson: row.validate_stats_json as string, + sampleCaveat: row.sample_caveat as string, + source: row.source as 'validated' | 'baseline', + isActive: Number(row.is_active) === 1, + }; } /** Build a rack from the catalog subset; validates slot ids exist. */ diff --git a/app/server/src/db/fundRepository.ts b/app/server/src/db/fundRepository.ts index f75c595..ca8f654 100644 --- a/app/server/src/db/fundRepository.ts +++ b/app/server/src/db/fundRepository.ts @@ -48,6 +48,29 @@ export interface LiveBookPosition { evidence_url: string | null; notes: string | null; post_text: string | null; + /** held = latest size > 0 or unknown; exited = latest shares are exactly 0. */ + status: 'held' | 'exited'; + /** Prior non-zero size, used when status is exited. */ + last_held_shares: number | null; + /** Same instrument on the previous disclosure (13F or capture). */ + prior_shares: number | null; + share_delta: number | null; + change: 'add' | 'cut' | 'flat' | 'new' | null; + /** Latest row is X, and this instrument never appeared in a 13F. */ + x_only: boolean; + evidence_status?: 'ok' | 'deleted' | 'unknown'; +} + +export function instrumentOf(notes: string | null | undefined): 'call' | 'put' | 'equity' { + if (notes === 'call' || notes === 'put') return notes; + return 'equity'; +} + +export function noteFromPutCall(putCall: string | null | undefined): string | null { + const p = (putCall || '').trim().toLowerCase(); + if (p === 'call') return 'call'; + if (p === 'put') return 'put'; + return null; } // ─── Tracked fund registry ─────────────────────────────────────────────────── @@ -125,6 +148,100 @@ export function deleteTrackedFund(db: DatabaseSync, id: string): boolean { return r.changes > 0; } +export type SourcePullStatus = 'pending' | 'in_flight' | 'done' | 'backoff' | 'failed' | 'none'; + +export interface SourcePull { + status: SourcePullStatus; + lastAttempt: string | null; + error: string | null; +} + +export interface FundFreshness { + xHandle: string | null; + xPull: SourcePull; + lastPostAt: string | null; + lastCaptureAt: string | null; + last13fAt: string | null; +} + +const EMPTY_PULL: SourcePull = { status: 'none', lastAttempt: null, error: null }; + +function asPullStatus(raw: string | null | undefined): SourcePullStatus { + if (raw === 'pending' || raw === 'in_flight' || raw === 'done' || raw === 'backoff' || raw === 'failed') return raw; + return 'none'; +} + +/** Last X pull / last post / last book write for each fund (one batched read). */ +export function fundsFreshness(db: DatabaseSync, funds: TrackedFund[]): Map { + const out = new Map(); + if (funds.length === 0) return out; + + const handles = [...new Set(funds.map((f) => f.x_handle).filter((h): h is string => Boolean(h)))]; + const queueByHandle = new Map(); + if (handles.length > 0) { + const keys = handles.flatMap((h) => [`x:timeline:${h}`, `x:timeline:${h.toLowerCase()}`]); + const rows = db.prepare( + `SELECT key, status, last_attempt, error FROM adapter_queue WHERE key IN (${keys.map(() => '?').join(',')})`, + ).all(...keys) as Array<{ key: string; status: string; last_attempt: string | null; error: string | null }>; + for (const r of rows) { + const handle = r.key.slice('x:timeline:'.length); + queueByHandle.set(handle, { + status: asPullStatus(r.status), + lastAttempt: r.last_attempt, + error: r.error, + }); + } + } + + const lastPostByHandle = new Map(); + if (handles.length > 0) { + const rows = db.prepare( + `SELECT lower(author_handle) AS h, MAX(posted_at) AS last_post + FROM x_cookie_posts + WHERE lower(author_handle) IN (${handles.map(() => '?').join(',')}) + GROUP BY 1`, + ).all(...handles.map((h) => h.toLowerCase())) as Array<{ h: string; last_post: string | null }>; + for (const r of rows) { + if (r.last_post) lastPostByHandle.set(r.h, r.last_post); + } + } + + const lastByFundSource = new Map(); + const ids = funds.map((f) => f.id); + const recs = db.prepare( + `SELECT fund_id, source, MAX(created_at) AS last_created + FROM fund_position_records + WHERE fund_id IN (${ids.map(() => '?').join(',')}) + GROUP BY fund_id, source`, + ).all(...ids) as Array<{ fund_id: string; source: string; last_created: string }>; + for (const r of recs) lastByFundSource.set(`${r.fund_id}:${r.source}`, r.last_created); + + for (const f of funds) { + const handle = f.x_handle; + const pull = handle + ? (queueByHandle.get(handle) ?? queueByHandle.get(handle.toLowerCase()) ?? EMPTY_PULL) + : EMPTY_PULL; + out.set(f.id, { + xHandle: handle, + xPull: pull, + lastPostAt: handle ? (lastPostByHandle.get(handle.toLowerCase()) ?? null) : null, + lastCaptureAt: lastByFundSource.get(`${f.id}:capture`) ?? null, + last13fAt: lastByFundSource.get(`${f.id}:13f`) ?? null, + }); + } + return out; +} + +export function fundFreshness(db: DatabaseSync, fund: TrackedFund): FundFreshness { + return fundsFreshness(db, [fund]).get(fund.id) ?? { + xHandle: fund.x_handle, + xPull: EMPTY_PULL, + lastPostAt: null, + lastCaptureAt: null, + last13fAt: null, + }; +} + // ─── Position records / Live Book ───────────────────────────────────────────── function rowToRecord(row: Record): FundPositionRecord { @@ -152,6 +269,7 @@ export interface NewPositionRecord { as_of: string; source: PositionSource; evidence_url?: string | null; + notes?: string | null; id?: string; // optional stable id (e.g. derived from 13F accession + symbol) for idempotency } @@ -160,12 +278,12 @@ export function insertPositionRecord(db: DatabaseSync, input: NewPositionRecord) const id = input.id ?? randomUUID(); const created_at = new Date().toISOString(); db.prepare( - `INSERT INTO fund_position_records (id, fund_id, symbol, shares, value_usd, cost_basis, as_of, source, evidence_url, created_at) - VALUES (?,?,?,?,?,?,?,?,?,?)`, + `INSERT INTO fund_position_records (id, fund_id, symbol, shares, value_usd, cost_basis, as_of, source, evidence_url, notes, created_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?)`, ).run( id, input.fund_id, input.symbol, input.shares ?? null, input.value_usd ?? null, input.cost_basis ?? null, - input.as_of, input.source, input.evidence_url ?? null, created_at, + input.as_of, input.source, input.evidence_url ?? null, input.notes ?? null, created_at, ); const row = db.prepare('SELECT * FROM fund_position_records WHERE id = ?').get(id) as Record; return rowToRecord(row); @@ -185,32 +303,82 @@ export function listRecords(db: DatabaseSync, fundId: string): FundPositionRecor */ export function liveBook(db: DatabaseSync, fundId: string): LiveBookPosition[] { const rows = db.prepare( - `SELECT fpr.*, xcp.body_text AS post_text, xcp.posted_at AS post_date + `SELECT fpr.*, xcp.body_text AS post_text, xcp.posted_at AS post_date, + ( + SELECT p2.shares FROM fund_position_records p2 + WHERE p2.fund_id = fpr.fund_id AND p2.symbol = fpr.symbol + AND CASE WHEN p2.notes IN ('call','put') THEN p2.notes ELSE '' END + = CASE WHEN fpr.notes IN ('call','put') THEN fpr.notes ELSE '' END + AND p2.shares IS NOT NULL AND p2.shares > 0 + ORDER BY p2.as_of DESC, p2.created_at DESC + LIMIT 1 + ) AS last_held_shares, + ( + SELECT p3.shares FROM fund_position_records p3 + WHERE p3.fund_id = fpr.fund_id AND p3.symbol = fpr.symbol + AND CASE WHEN p3.notes IN ('call','put') THEN p3.notes ELSE '' END + = CASE WHEN fpr.notes IN ('call','put') THEN fpr.notes ELSE '' END + AND p3.shares IS NOT NULL AND p3.shares > 0 + AND p3.as_of < fpr.as_of + ORDER BY p3.as_of DESC, p3.created_at DESC + LIMIT 1 + ) AS prior_shares, + EXISTS ( + SELECT 1 FROM fund_position_records p4 + WHERE p4.fund_id = fpr.fund_id AND p4.symbol = fpr.symbol + AND p4.source = '13f' AND p4.shares IS NOT NULL AND p4.shares > 0 + AND CASE WHEN p4.notes IN ('call','put') THEN p4.notes ELSE '' END + = CASE WHEN fpr.notes IN ('call','put') THEN fpr.notes ELSE '' END + ) AS has_13f FROM fund_position_records fpr LEFT JOIN x_cookie_posts xcp ON ( fpr.source = 'capture' AND xcp.post_id = SUBSTR(fpr.evidence_url, INSTR(fpr.evidence_url, '/status/') + 8) ) JOIN ( - SELECT symbol, MAX(as_of) AS max_as_of + SELECT symbol, + CASE WHEN notes IN ('call','put') THEN notes ELSE '' END AS inst, + MAX(as_of) AS max_as_of FROM fund_position_records WHERE fund_id = ? - GROUP BY symbol + GROUP BY symbol, CASE WHEN notes IN ('call','put') THEN notes ELSE '' END ) latest ON latest.symbol = fpr.symbol AND latest.max_as_of = fpr.as_of + AND CASE WHEN fpr.notes IN ('call','put') THEN fpr.notes ELSE '' END = latest.inst WHERE fpr.fund_id = ? ORDER BY fpr.as_of DESC, fpr.symbol ASC`, ).all(fundId, fundId) as Array>; - return rows.map((r) => ({ - symbol: r.symbol, - shares: r.shares ?? null, - value_usd: r.value_usd ?? null, - cost_basis: r.cost_basis ?? null, - as_of: r.as_of, - source: r.source as PositionSource, - evidence_url: r.evidence_url ?? null, - notes: r.notes ?? null, - post_text: r.post_text ?? null, - })); + return rows.map((r) => { + const shares = r.shares ?? null; + const exited = shares === 0; + const prior = r.prior_shares ?? null; + let change: LiveBookPosition['change'] = null; + let shareDelta: number | null = null; + if (!exited) { + if (prior == null) { + change = 'new'; + } else if (shares != null) { + shareDelta = shares - prior; + change = shareDelta > 0 ? 'add' : shareDelta < 0 ? 'cut' : 'flat'; + } + } + return { + symbol: r.symbol, + shares, + value_usd: r.value_usd ?? null, + cost_basis: r.cost_basis ?? null, + as_of: r.as_of, + source: r.source as PositionSource, + evidence_url: r.evidence_url ?? null, + notes: r.notes ?? null, + post_text: r.post_text ?? null, + status: exited ? 'exited' as const : 'held' as const, + last_held_shares: r.last_held_shares ?? null, + prior_shares: prior, + share_delta: shareDelta, + change, + x_only: (r.source === 'capture' || r.source === 'claim') && !r.has_13f, + }; + }); } // ─── 13F sync (per-fund-13f-ingest, sub-task 3) ────────────────────────────── @@ -226,7 +394,7 @@ export function sync13FIntoRecords(db: DatabaseSync, fundId: string): number { | undefined; if (!fund) return 0; const rows = db.prepare( - `SELECT symbol, shares, value_usd, reported_quarter, accession + `SELECT symbol, shares, value_usd, reported_quarter, accession, put_call FROM institution_filings WHERE filer_cik = ? AND form = '13F-HR' AND shares IS NOT NULL`, ).all(fund.ci_key) as Array>; @@ -236,24 +404,99 @@ export function sync13FIntoRecords(db: DatabaseSync, fundId: string): number { for (const r of rows) { const asOf = quarterEnd(r.reported_quarter as string); const evidence = r.accession ? `sec|${r.accession}` : null; + const notes = noteFromPutCall(r.put_call as string | null); + const inst = notes ?? ''; if (evidence) { - // Converge: refresh the record when the 13F source row changed. - const upd = db.prepare( - `UPDATE fund_position_records SET shares=?, value_usd=?, as_of=? WHERE fund_id=? AND symbol=? AND source='13f' AND evidence_url=?`, - ).run(r.shares, r.value_usd, asOf, fundId, r.symbol, evidence); + let upd = db.prepare( + `UPDATE fund_position_records SET shares=?, value_usd=?, as_of=?, notes=? + WHERE fund_id=? AND symbol=? AND source='13f' AND evidence_url=? + AND CASE WHEN notes IN ('call','put') THEN notes ELSE '' END = ?`, + ).run(r.shares, r.value_usd, asOf, notes, fundId, r.symbol, evidence, inst); + if (upd.changes === 0 && notes) { + // One-time: unlabeled 13F row for a name that is only options. + upd = db.prepare( + `UPDATE fund_position_records SET shares=?, value_usd=?, as_of=?, notes=? + WHERE fund_id=? AND symbol=? AND source='13f' AND evidence_url=? + AND (notes IS NULL OR notes = '')`, + ).run(r.shares, r.value_usd, asOf, notes, fundId, r.symbol, evidence); + } if (upd.changes > 0) { updated++; continue; } } else { const existing = db.prepare( - `SELECT id FROM fund_position_records WHERE fund_id=? AND symbol=? AND source='13f' AND evidence_url IS NULL AND as_of=?`, - ).get(fundId, r.symbol, asOf) as { id?: string } | undefined; + `SELECT id FROM fund_position_records WHERE fund_id=? AND symbol=? AND source='13f' + AND evidence_url IS NULL AND as_of=? + AND CASE WHEN notes IN ('call','put') THEN notes ELSE '' END = ?`, + ).get(fundId, r.symbol, asOf, inst) as { id?: string } | undefined; if (existing) continue; } insertPositionRecord(db, { fund_id: fundId, symbol: r.symbol, shares: r.shares, value_usd: r.value_usd, - as_of: asOf, source: '13f', evidence_url: evidence, + as_of: asOf, source: '13f', evidence_url: evidence, notes, + }); + inserted++; + } + + inserted += synthesize13FExits(db, fundId, fund.ci_key); + return inserted; +} + +/** + * Names in an earlier 13F quarter that the latest quarter omitted become + * zero-share 13F records as of that quarter-end. + */ +function synthesize13FExits(db: DatabaseSync, fundId: string, filerCik: string): number { + const latest = db.prepare( + `SELECT MAX(reported_quarter) AS q FROM institution_filings + WHERE filer_cik = ? AND form = '13F-HR' AND shares IS NOT NULL AND shares > 0`, + ).get(filerCik) as { q: string | null }; + if (!latest?.q) return 0; + + const latestRows = db.prepare( + `SELECT symbol, accession, put_call FROM institution_filings + WHERE filer_cik = ? AND form = '13F-HR' AND reported_quarter = ? + AND shares IS NOT NULL AND shares > 0`, + ).all(filerCik, latest.q) as Array<{ symbol: string; accession: string | null; put_call: string | null }>; + if (latestRows.length === 0) return 0; + + const instKey = (symbol: string, putCall: string | null | undefined) => + `${symbol}|${noteFromPutCall(putCall) ?? ''}`; + const latestKeys = new Set(latestRows.map((r) => instKey(r.symbol, r.put_call))); + const accession = latestRows.find((r) => r.accession)?.accession ?? null; + const asOf = quarterEnd(latest.q); + const evidence = accession ? `sec|${accession}` : null; + + const prior = db.prepare( + `SELECT DISTINCT symbol, put_call FROM institution_filings + WHERE filer_cik = ? AND form = '13F-HR' AND reported_quarter < ? + AND shares IS NOT NULL AND shares > 0`, + ).all(filerCik, latest.q) as Array<{ symbol: string; put_call: string | null }>; + + let inserted = 0; + for (const row of prior) { + const notes = noteFromPutCall(row.put_call); + if (latestKeys.has(instKey(row.symbol, row.put_call))) continue; + const inst = notes ?? ''; + if (evidence) { + const upd = db.prepare( + `UPDATE fund_position_records SET shares=0, value_usd=0, as_of=? + WHERE fund_id=? AND symbol=? AND source='13f' AND evidence_url=? + AND CASE WHEN notes IN ('call','put') THEN notes ELSE '' END = ?`, + ).run(asOf, fundId, row.symbol, evidence, inst); + if (upd.changes > 0) continue; + } else { + const existing = db.prepare( + `SELECT id FROM fund_position_records + WHERE fund_id=? AND symbol=? AND source='13f' AND evidence_url IS NULL AND as_of=? AND shares=0 + AND CASE WHEN notes IN ('call','put') THEN notes ELSE '' END = ?`, + ).get(fundId, row.symbol, asOf, inst) as { id?: string } | undefined; + if (existing) continue; + } + insertPositionRecord(db, { + fund_id: fundId, symbol: row.symbol, shares: 0, value_usd: 0, + as_of: asOf, source: '13f', evidence_url: evidence, notes, }); inserted++; } diff --git a/app/server/src/db/schema.sql b/app/server/src/db/schema.sql index b1edcd1..090f3db 100644 --- a/app/server/src/db/schema.sql +++ b/app/server/src/db/schema.sql @@ -520,6 +520,7 @@ CREATE TABLE IF NOT EXISTS x_cookie_posts ( sentiment_score REAL, -- -1.0 to +1.0 attribution TEXT, -- original author handle (may differ from author_handle for quotes/retweets) cached_until TEXT NOT NULL, -- 7d rolling; rows stale after this + quoted_post_id TEXT, -- native quote target, when known PRIMARY KEY (post_id) ); @@ -1019,6 +1020,23 @@ CREATE TABLE IF NOT EXISTS confluence_signal_history ( verdict TEXT -- real|false_alarm ); +-- Walk-forward derived entry/exit rules (one active pair per rack). +CREATE TABLE IF NOT EXISTS confluence_zone_rules ( + id TEXT PRIMARY KEY, + rack_id TEXT NOT NULL REFERENCES confluence_racks(id) ON DELETE CASCADE, + kind TEXT NOT NULL, -- entry | exit + version INTEGER NOT NULL, + derived_at TEXT NOT NULL, + predicate_json TEXT NOT NULL, + train_stats_json TEXT NOT NULL, + validate_stats_json TEXT NOT NULL, + sample_caveat TEXT NOT NULL, + source TEXT NOT NULL, -- validated | baseline + is_active INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_confluence_zone_rules_rack + ON confluence_zone_rules(rack_id, kind, is_active); + -- ===== Price Corridor module (M24). ===== -- Daily valuation-corridor snapshots per symbol: the observable forward/trailing -- P/E corridor the Corridor Method derives entry timing and implied upside from. diff --git a/app/server/src/index.ts b/app/server/src/index.ts index 0bc28e7..d19c135 100644 --- a/app/server/src/index.ts +++ b/app/server/src/index.ts @@ -238,17 +238,59 @@ housekeepTimer.unref(); // over the confluence universe, persisting rack evaluations + signal history. // Idempotent per (symbol, asOf, rack) — cheap to run more often than daily. const CONFLUENCE_EVAL_MS = 60 * 60 * 1000; // hourly tick; re-eval only when stale +let lastZoneDeriveAt = 0; +const ZONE_DERIVE_MS = 7 * 24 * 60 * 60 * 1000; + +async function confluenceTick(label: string): Promise { + const { + runConfluenceEvaluationCycle, + runConfluenceReplay, + deriveAndPersistZoneRules, + fillLearningLedger, + LEARNING_REPLAY_DAYS, + LEARNING_REPLAY_SYMBOLS, + } = await import('./confluence/confluenceEngine.ts'); + const { CONFLUENCE_LEARNING_UNIVERSE } = await import('./confluence/confluenceSeed.ts'); + const replay = label === 'boot' + ? await fillLearningLedger(database, cache, 75_000) + : await runConfluenceReplay(database, cache, { + symbols: CONFLUENCE_LEARNING_UNIVERSE, + symbolsPerTick: LEARNING_REPLAY_SYMBOLS, + budgetDaysPerSymbol: LEARNING_REPLAY_DAYS, + }); + if (replay.evaluationsStored > 0 || replay.symbolsTouched.length > 0) { + console.log( + `[confluence] ${label} replay stored=${replay.evaluationsStored} symbols=${replay.symbolsTouched.join(',')} ` + + `remainingDays=${replay.remainingDays} complete=${replay.complete}`, + ); + } + const summary = await runConfluenceEvaluationCycle(database, cache); + if (summary.symbolsEvaluated.length > 0) { + console.log( + `[confluence] ${label} evaluated=${summary.symbolsEvaluated.length} stored=${summary.evaluationsStored} ` + + `reused=${summary.evaluationsReused} signals=${summary.signalsLogged} resolved=${summary.signalsResolved} ` + + `corridorSnapshots=${summary.corridorSnapshots}`, + ); + } + const now = Date.now(); + if (now - lastZoneDeriveAt > ZONE_DERIVE_MS || lastZoneDeriveAt === 0) { + try { + const derived = await deriveAndPersistZoneRules(database, cache); + if (derived.skipped) { + console.log(`[confluence] zone rules deferred: ${derived.skipped}`); + } else { + lastZoneDeriveAt = now; + console.log(`[confluence] zone rules derived source=${derived.source}`); + } + } catch (e) { + console.error('[confluence] zone rule derive failed:', e); + } + } +} + const confluenceEvalTimer = setInterval(async () => { try { - const { runConfluenceEvaluationCycle } = await import('./confluence/confluenceEngine.ts'); - const summary = await runConfluenceEvaluationCycle(database, cache); - if (summary.symbolsEvaluated.length > 0) { - console.log( - `[confluence] evaluated=${summary.symbolsEvaluated.length} stored=${summary.evaluationsStored} ` + - `reused=${summary.evaluationsReused} signals=${summary.signalsLogged} resolved=${summary.signalsResolved} ` + - `corridorSnapshots=${summary.corridorSnapshots}`, - ); - } + await confluenceTick('hourly'); } catch (e) { console.error('[confluence] evaluation cycle failed:', e); } @@ -257,11 +299,7 @@ confluenceEvalTimer.unref(); // First cycle shortly after boot once the EOD refresh has had a chance to land. setTimeout(async () => { try { - const { runConfluenceEvaluationCycle } = await import('./confluence/confluenceEngine.ts'); - const summary = await runConfluenceEvaluationCycle(database, cache); - if (summary.symbolsEvaluated.length > 0) { - console.log(`[confluence] boot cycle: ${summary.symbolsEvaluated.length} symbols, ${summary.evaluationsStored} evaluations stored`); - } + await confluenceTick('boot'); } catch (e) { console.error('[confluence] boot cycle failed:', e); } diff --git a/app/server/src/mirror/__tests__/captureParser.test.ts b/app/server/src/mirror/__tests__/captureParser.test.ts index 2838c09..4ed8c15 100644 --- a/app/server/src/mirror/__tests__/captureParser.test.ts +++ b/app/server/src/mirror/__tests__/captureParser.test.ts @@ -1,7 +1,7 @@ // Capture pipeline classification/parser tests (sub-task 4). import { test } from 'node:test'; import { strict as assert } from 'node:assert'; -import { classifyPost, extractCaptures } from '../captureParser.ts'; +import { classifyPost, extractCaptures, extractRemainingBookTickers, inferResetExits } from '../captureParser.ts'; test('capture: total-position post with numbers', () => { const r = classifyPost('IREN position: 750,000 shares, avg cost $22.40, market value $28.3M.'); @@ -19,6 +19,26 @@ test('capture: totals without explicit marker still parse via position/holdings' assert.ok(Math.abs(r.cost_basis! - 0.74) < 0.001); }); +test('claim: follow-on add with average now prefers 1.69 over the clip print', () => { + const r = classifyPost( + "Quick update on this one. I added another 123,000 shares to this today at $1.62 net. Average now $1.69.", + ); + assert.equal(r.class, 'claim'); + assert.equal(r.symbol, undefined); + assert.equal(r.shares, 123000); + assert.ok(Math.abs((r.cost_basis ?? 0) - 1.69) < 0.001); +}); + +test('claim: Mike ENHA add is a claim with ticker + size + print', () => { + const r = classifyPost( + "Seeing some very strange trading in ENHA. So without even thinking about it, I added 115,000 shares at $1.79. I still believe in my friend @C_Angermayer's vision. Still too early to write this one off.", + ); + assert.equal(r.class, 'claim'); + assert.equal(r.symbol, 'ENHA'); + assert.equal(r.shares, 115000); + assert.ok(Math.abs((r.cost_basis ?? 0) - 1.79) < 0.001); +}); + test('claim: "added n shares at x average price"', () => { const r = classifyPost('Added 865,000 shares of BKKT at $3.10 average price today.'); assert.equal(r.class, 'claim'); @@ -35,6 +55,39 @@ test('claim: bought / sold with quantity', () => { assert.equal(sold.class, 'claim'); }); +test('full exit: sold out of $PEP is a zero-share capture', () => { + const r = classifyPost('Sold out of $PEP this morning. Moving on.'); + assert.equal(r.class, 'capture'); + assert.equal(r.symbol, 'PEP'); + assert.equal(r.shares, 0); +}); + +test('full exit: sold all of / closed the position / no longer hold / fully out / flat in', () => { + assert.equal(classifyPost('Sold all of my DEO.').shares, 0); + assert.equal(classifyPost('Sold all of my DEO.').symbol, 'DEO'); + assert.equal(classifyPost('Closed out the BMY position yesterday.').symbol, 'BMY'); + assert.equal(classifyPost('Closed out the BMY position yesterday.').shares, 0); + assert.equal(classifyPost('We no longer hold LEN.').symbol, 'LEN'); + assert.equal(classifyPost('Fully out of $GLPI.').symbol, 'GLPI'); + assert.equal(classifyPost('Flat in $PYPL now.').symbol, 'PYPL'); + assert.equal(classifyPost('Flat in $PYPL now.').shares, 0); +}); + +test('full exit: sold 2,000 shares of CIFR stays a claim, not an exit', () => { + const r = classifyPost('Sold 2,000 shares of CIFR today.'); + assert.equal(r.class, 'claim'); + assert.notEqual(r.shares, 0); +}); + +test('full exit: closed the gap / tax-bill sell / sold everything are not exits', () => { + assert.equal(classifyPost('They closed the gap at yesterday\'s open in CIFR.').class, 'none'); + assert.equal( + classifyPost('Sold $1.7M of income producing securities in my personal accounts this morning in order to cover the remainder of my 2025 federal tax bill.').class, + 'none', + ); + assert.equal(classifyPost('Sold everything. Cleaning house.').class, 'none'); +}); + test('commentary: no numbers → never a record', () => { const r = classifyPost('Huge week for the portfolio. Love the setup here.'); assert.equal(r.class, 'none'); @@ -131,3 +184,39 @@ OPEN now over 6.45M shares. Have taken it up on the weakness. RKT/Z also weak. S assert.equal(slnh!.class, 'claim'); assert.equal(slnh!.shares, 100_000); }); + +test('book reset: what stayed lists remaining tickers', () => { + const kept = extractRemainingBookTickers('Cleaning the book. What stayed: $CIFR $IREN $OPEN'); + assert.ok(kept); + assert.deepEqual([...kept!].sort(), ['CIFR', 'IREN', 'OPEN']); +}); + +test('book reset: top-10 list is not a remaining-book list', () => { + assert.equal(extractRemainingBookTickers('Top 10 positions excluding ETFs:\n1. $CIFR\n2. $IREN\n3. $OPEN'), null); +}); + +test('Mr T: back in $OUST at a price is a hold, not a skipped claim', () => { + const multi = extractCaptures('back in $OUST.\n\nbought again a few days ago at $46.02. this is the exact name that gave us 40% earlier this year.'); + const oust = multi.find((c) => c.symbol === 'OUST'); + assert.ok(oust); + assert.equal(oust!.class, 'capture'); + assert.ok(Math.abs((oust!.cost_basis ?? 0) - 46.02) < 0.001); +}); + +test('Mr T: buyingg multi-name list materializes each ticker', () => { + const multi = extractCaptures('buyingg $be 238.95 $bksy 31.86 $oust 46.02\n\nim gonna + more'); + assert.deepEqual(multi.map((c) => c.symbol).sort(), ['BE', 'BKSY', 'OUST']); + assert.ok(multi.every((c) => c.class === 'capture')); + assert.ok(Math.abs((multi.find((c) => c.symbol === 'BE')!.cost_basis ?? 0) - 238.95) < 0.001); +}); + +test('Mr T: trim post is not a reentry hold', () => { + const multi = extractCaptures('trimmed some $CBRS today.\n\nbought at 225 last week. sold some at 256.'); + assert.ok(!multi.some((c) => c.class === 'capture' && c.symbol === 'CBRS')); +}); + +test('book reset: infer exits for names held but absent from the stayed list', () => { + const exits = inferResetExits(['CIFR', 'IREN', 'DEO', 'BMY'], ['CIFR', 'IREN']); + assert.deepEqual(exits.map((e) => e.symbol).sort(), ['BMY', 'DEO']); + assert.ok(exits.every((e) => e.class === 'capture' && e.shares === 0)); +}); diff --git a/app/server/src/mirror/__tests__/mirrorEngine.test.ts b/app/server/src/mirror/__tests__/mirrorEngine.test.ts index 20168d4..3db5cfe 100644 --- a/app/server/src/mirror/__tests__/mirrorEngine.test.ts +++ b/app/server/src/mirror/__tests__/mirrorEngine.test.ts @@ -68,6 +68,24 @@ test('rounding: whole-share tick by default, fractional with tick 0.01', () => { assert.ok(Math.abs(r2.rows[0].target_qty! - 479.62) < 0.011); }); +test('zero-share exits are dropped from weights; user holding gets close-to-zero delta', () => { + const book = [ + { symbol: 'IREN', shares: 100, value_usd: 5000, cost_basis: 40 }, + { symbol: 'DEO', shares: 0, value_usd: 0, cost_basis: null }, + ]; + const prices = { IREN: 50, DEO: 100 }; + const r = mirrorBook(book, [{ symbol: 'DEO', qty: 20, avg_cost: 80 }], prices, { base: 10000, minPositionUsd: 1 }); + assert.equal(r.rows.some((x) => x.symbol === 'IREN'), true); + assert.ok(Math.abs(r.rows.find((x) => x.symbol === 'IREN')!.weight - 1) < 0.001); + const deo = r.rows.find((x) => x.symbol === 'DEO'); + assert.ok(deo, 'user still holds the exited name'); + assert.equal(deo!.weight, 0); + assert.equal(deo!.target_qty, 0); + assert.equal(deo!.delta_qty, -20); + assert.match(deo!.message, /delta/i); + assert.ok(!/\byou should\b/i.test(deo!.message)); +}); + test('ADR-0010: messages are mechanical (never advice-shaped)', () => { const book = [{ symbol: 'IREN', shares: 100, value_usd: 5000, cost_basis: 40 }]; const prices = { IREN: 50 }; diff --git a/app/server/src/mirror/captureParser.ts b/app/server/src/mirror/captureParser.ts index 167937b..c1452c1 100644 --- a/app/server/src/mirror/captureParser.ts +++ b/app/server/src/mirror/captureParser.ts @@ -101,6 +101,10 @@ function findValue(text: string): number | undefined { } function findCostBasis(text: string): number | undefined { + // Stated book average wins over the print on this clip ("Average now $1.69" + // after "added … at $1.62"). + const nowAvg = text.match(/\baverage now\s+\$?\s*([\d.,]+)/i); + if (nowAvg) return toNum(nowAvg[1]); // "at $22.40 avg" / "avg cost $22.40" / "cost basis $22.40" / "@ $7" / // "average down to $4.42" / "at a $1.25 average" const m = text.match(/(?:at|avg(?: cost)?|average|cost basis|basis|bought at)[^$\d]{0,16}\$\s*([\d.,]+)/i) @@ -108,6 +112,60 @@ function findCostBasis(text: string): number | undefined { return m ? toNum(m[1]) : undefined; } +/** + * Full-exit language (position is gone), not a sized trim. + * "Sold 2,000 shares of CIFR" is a claim and must not match. + */ +export function extractFullExit(text: string): ParsedCapture | null { + const t = text || ''; + const patterns: RegExp[] = [ + /\bsold\s+out\s+of\s+\$?([A-Za-z]{1,5})\b/i, + /\bsold\s+all\s+of\s+(?:my\s+|the\s+)?\$?([A-Za-z]{1,5})\b/i, + /\bclosed\s+(?:out\s+)?the\s+\$?([A-Za-z]{1,5})\s+position\b/i, + /\bno\s+longer\s+holds?\s+\$?([A-Za-z]{1,5})\b/i, + /\bfully\s+out\s+of\s+\$?([A-Za-z]{1,5})\b/i, + /\bflat\s+in\s+\$([A-Za-z]{1,5})\b/i, + /\bflat\s+in\s+([A-Z]{2,5})\b/, + ]; + for (const re of patterns) { + const m = t.match(re); + if (!m || !isTickerCandidate(m[1])) continue; + return { class: 'capture', symbol: m[1].toUpperCase(), shares: 0, instrument: 'equity' }; + } + return null; +} + +const REMAINING_BOOK_MARKERS = + /\b(?:what stayed|cleaning the book|sold almost everything|book is now)\b/i; + +/** Tickers named as the remaining book, or null if this is not a reset post. */ +export function extractRemainingBookTickers(text: string): Set | null { + const t = text || ''; + if (!REMAINING_BOOK_MARKERS.test(t)) return null; + const out = new Set(); + for (const m of t.matchAll(/\$([A-Za-z]{1,5})\b/g)) { + if (isTickerCandidate(m[1])) out.add(m[1].toUpperCase()); + } + const after = t.match( + /\b(?:what stayed|kept|still (?:holding|long)|remain(?:ing)?|book is now)\s*:?\s*([^\n]+)/i, + ); + if (after) { + for (const tok of after[1].match(/\b[A-Z]{1,5}\b/g) ?? []) { + if (isTickerCandidate(tok)) out.add(tok); + } + } + return out; +} + +/** Names held before a reset post that the stayed-list omitted. */ +export function inferResetExits(heldSymbols: string[], stayed: Iterable): ParsedCapture[] { + const keep = new Set([...stayed].map((s) => s.toUpperCase())); + return heldSymbols + .map((s) => s.toUpperCase()) + .filter((s) => s && !keep.has(s)) + .map((symbol) => ({ class: 'capture' as const, symbol, shares: 0, instrument: 'equity' as const })); +} + function findInstrument(text: string): InstrumentKind { // "WULF calls", "$20 Sept WULF calls", "buying calls on WULF" if (/\b[A-Z]{1,5}\s+calls?\b/.test(text) || /\bcalls?\s+(?:on|in)\s+/i.test(text) || /\bcall options?\b/i.test(text)) { @@ -214,6 +272,9 @@ export function extractCaptures(text: string): ParsedCapture[] { out.push(p); }; + const fullExit = extractFullExit(t); + if (fullExit) push(fullExit); + // Totals: "OPEN now over 6.45M shares" / "SLNH now at 3.09M shares" for (const m of t.matchAll( /\b([A-Z]{1,5})\s+now\s+(?:over|at|above|about|under|~|more than)?\s*([\d.,]+)\s*([MBKmbk])?\s*shares\b/g, @@ -271,14 +332,71 @@ export function extractCaptures(text: string): ParsedCapture[] { return out; } + if (extractRemainingBookTickers(t)) { + return out; + } + + for (const p of extractReentryCaptures(t)) push(p); + if (out.length > 0) return out; + // Single-capture path const one = classifyPost(t); return one.class === 'none' ? [] : [one]; } +const REENTRY_HOLD = + /\bback in\b|\bnew position\b|\bbuyingg?\b|\bbought again\b/i; + +/** + * Mr T style: "back in $OUST" / "buyingg $BE 238.95 $BKSY 31.86". + * These are current-book holds, not sized trims ("sold 2,000 shares"). + */ +export function extractReentryCaptures(text: string): ParsedCapture[] { + const t = text || ''; + if (!REENTRY_HOLD.test(t)) return []; + if (/\btrimmed?\b|\bsold some\b|\bshaving\b/i.test(t) && !/\bback in\b|\bnew position\b|\bbuyingg?\b/i.test(t)) { + return []; + } + const out: ParsedCapture[] = []; + const seen = new Set(); + const push = (symbol: string, cost?: number) => { + const sym = symbol.toUpperCase(); + // Cashtags are explicit ($BE = Bloom Energy); skip only 1-letter junk. + if (sym.length < 2 || sym.length > 5 || !/^[A-Z]+$/.test(sym)) return; + const existing = out.find((p) => p.symbol === sym); + if (existing) { + if (cost !== undefined && existing.cost_basis === undefined) existing.cost_basis = cost; + return; + } + seen.add(sym); + out.push({ + class: 'capture', + symbol: sym, + cost_basis: cost, + instrument: 'equity', + }); + }; + + for (const m of t.matchAll(/\b(?:back in|new position(?:\s*[-–])?|buyingg?|bought again(?:\s+in)?)\s+\$([A-Za-z]{1,5})\b/gi)) { + push(m[1]); + } + if (/\bbuyingg?\b/i.test(t)) { + for (const m of t.matchAll(/\$([A-Za-z]{1,5})\s+(\d+(?:\.\d+)?)/g)) { + push(m[1], Number(m[2])); + } + } + const cost = findCostBasis(t); + if (out.length === 1 && cost !== undefined && out[0].cost_basis === undefined) { + out[0].cost_basis = cost; + } + return out; +} + /** Classify a post and extract structured numbers. Deterministic. */ export function classifyPost(text: string): ParsedCapture { const t = text || ''; + const fullExit = extractFullExit(t); + if (fullExit) return fullExit; const qty = findQty(t); const cost = findCostBasis(t); const value = findValue(t); @@ -306,8 +424,13 @@ export function classifyPost(text: string): ParsedCapture { const base = { symbol, shares, cost_basis: cost, value_usd: value, instrument }; + const reentry = extractReentryCaptures(t); + if (reentry.length === 1) { + return { ...reentry[0], shares, cost_basis: reentry[0].cost_basis ?? cost, value_usd: value }; + } + const claimHit = CLAIM_MARKERS.some((re) => re.test(t)); - if (claimHit && (qty !== undefined || cost !== undefined)) { + if (claimHit && (qty !== undefined || cost !== undefined) && reentry.length === 0) { // Claims stay equity-oriented for v1 (delta folding is future work). return { class: 'claim', ...base, shares: qty }; } @@ -324,13 +447,16 @@ export function classifyPost(text: string): ParsedCapture { // Conversational captures / option initiations: "buying ... WULF calls", // "bought $X", "initiating position". const strongAction = /\b(?:bought|added|adding|buying|buy|initiat(?:ing|ed)|new (?:position|entry)|we (?:buy|adding))\b/i.test(t); - if (strongAction && symbol) { + const isTrim = /\btrimmed?\b|\bsold some\b|\bshaving\b/i.test(t); + if (strongAction && symbol && !isTrim) { return { class: 'capture', ...base }; } // Position list patterns: "what stayed: $X $Y $Z" / "kept: $X $Y" + // Only a capture when the post also has a size; a bare stayed-list is + // handled by extractRemainingBookTickers + inferResetExits at ingest. const listPattern = /\b(?:what stayed|kept|still (?:holding|long)|remain(?:ing)?)\s*:/i.test(t); - if (listPattern && symbol) { + if (listPattern && symbol && hasAccountNumbers) { return { class: 'capture', ...base }; } diff --git a/app/server/src/mirror/fund13fFetcher.ts b/app/server/src/mirror/fund13fFetcher.ts index 254a8bf..26dfb96 100644 --- a/app/server/src/mirror/fund13fFetcher.ts +++ b/app/server/src/mirror/fund13fFetcher.ts @@ -92,7 +92,7 @@ export async function fetchFund13F(db: DatabaseSync, fundId: string): Promise; holdingsSeen += holdings.length; - // Aggregate per symbol (13F lists shares + separate put/call rows for the - // same name; uq_inst_filings allows one row per (cik, symbol, quarter, form)). - const bySymbol = new Map(); + // Keep common stock and listed options on separate rows (same CUSIP, different putCall). + const byKey = new Map(); for (const h of holdings) { const symbol = cusipToSymbol(db, h.cusip, h.issuerName); if (!symbol) { unresolved.push({ cusip: h.cusip, issuerName: h.issuerName }); continue; } - const agg = bySymbol.get(symbol) ?? { shares: 0, value: 0, putCall: '' }; + const putCall = (h.putCall || '').trim(); + const key = `${symbol}\0${putCall}`; + const agg = byKey.get(key) ?? { symbol, shares: 0, value: 0, putCall }; agg.shares += h.sshPrnamt ?? 0; agg.value += h.value ?? 0; - if (h.putCall) agg.putCall = h.putCall; - bySymbol.set(symbol, agg); + byKey.set(key, agg); } - for (const [symbol, agg] of bySymbol) { + for (const agg of byKey.values()) { if (agg.shares <= 0 && agg.value <= 0) continue; upsert.run( - fund.ci_key, null, symbol, agg.shares, agg.value, quarter, fileDate, now, accession, - agg.putCall || null, + fund.ci_key, null, agg.symbol, agg.shares, agg.value, quarter, fileDate, now, accession, + agg.putCall, ); upserted++; } diff --git a/app/server/src/mirror/mirrorEngine.ts b/app/server/src/mirror/mirrorEngine.ts index cb038d3..a51d467 100644 --- a/app/server/src/mirror/mirrorEngine.ts +++ b/app/server/src/mirror/mirrorEngine.ts @@ -6,7 +6,7 @@ // re-baselines to actual equity; the `locked` option pins a fixed-size sleeve. // // ADR-0010: the ONLY strings this module can produce are MECHANICAL (arithmetic) -// statements — "to match your mirror target, the delta is N shares (~$X)". It never +// statements — "to match the tracked fund book, the delta is N shares (~$X)". It never // forms a recommending imperative ("act on this because the fund did"). The // ADR-0010 boundary is also enforced by the M21 Primary-Rule lint (sub-task 9). @@ -87,7 +87,7 @@ function fmtUsd(value: number): string { function buildMessage(symbol: string, deltaQty: number, deltaValue: number): string { const qty = Math.abs(Math.round(deltaQty * 100) / 100); const verb = deltaQty >= 0 ? 'increase' : 'trim'; - return `To match your mirror target for ${symbol}, the delta is ${verb} of ${qty} shares (about $${fmtUsd(Math.abs(deltaValue))}).`; + return `To match the tracked fund book for ${symbol}, the delta is ${verb} of ${qty} shares (about $${fmtUsd(Math.abs(deltaValue))}).`; } /** @@ -116,10 +116,17 @@ export function mirrorBook( const bookValueMap = new Map(); // symbol -> fund avg cost for (const p of book) bookValueMap.set(p.symbol.toUpperCase(), p.cost_basis ?? null); + const active = book.filter((p) => p.shares == null || p.shares > 0); + const exitedHeld = book.filter((p) => { + if (p.shares !== 0) return false; + const hold = actualBy.get(p.symbol.toUpperCase()); + return !!hold && hold.qty > 0; + }); + // Mark the fund book to current market: prefer shares × price; fall back to the // recorded value when we lack shares or a price. let bookValue = 0; - const val = book.map((p) => { + const val = active.map((p) => { const sym = p.symbol.toUpperCase(); const price = prices[sym]; let value: number; @@ -169,6 +176,29 @@ export function mirrorBook( (belowFloor ? excluded : rows).push(row); } + for (const p of exitedHeld) { + const sym = p.symbol.toUpperCase(); + const hold = actualBy.get(sym)!; + const price = prices[sym]; + const actualQty = hold.qty; + const actualValue = typeof price === 'number' && price > 0 ? actualQty * price : 0; + const delta = 0 - actualQty; + const deltaVal = delta * (price ?? 0); + rows.push({ + symbol: sym, + weight: 0, + fund_avg_cost: bookValueMap.get(sym) ?? null, + target_value: 0, + target_qty: 0, + actual_qty: actualQty, + actual_value: actualValue, + delta_qty: roundTick(delta, tick), + delta_value: deltaVal, + message: buildMessage(sym, delta, deltaVal), + excluded: false, + }); + } + const order = (a: MirrorRow, b: MirrorRow) => b.weight - a.weight || a.symbol.localeCompare(b.symbol); rows.sort(order); excluded.sort(order); diff --git a/app/server/src/options/OptionsChainRouter.ts b/app/server/src/options/OptionsChainRouter.ts index 33adc9a..757db49 100644 --- a/app/server/src/options/OptionsChainRouter.ts +++ b/app/server/src/options/OptionsChainRouter.ts @@ -141,12 +141,20 @@ export function assembleSurfaceFromCache( expiries = fromChains.map((r) => r.expiry); } - const asOfDay = (opts.asOf ?? new Date().toISOString()).slice(0, 10); + let newestChain: string | null = null; + for (const expiry of expiries) { + const row = db.prepare( + 'SELECT MAX(ts) AS ts FROM options_chains WHERE symbol=? AND expiry=?', + ).get(symbol, expiry) as { ts: string | null } | undefined; + if (row?.ts && (!newestChain || row.ts > newestChain)) newestChain = row.ts; + } + + const asOf = opts.asOf ?? newestChain ?? quote.observed_at; + const asOfDay = asOf.slice(0, 10); const liveExpiries = expiries.filter((e) => e >= asOfDay).slice(0, maxExpiries); if (liveExpiries.length === 0) return null; const rows: NormalizedOptionRow[] = []; - let latestTs = quote.observed_at; for (const expiry of liveExpiries) { // Latest snapshot per (strike, type) for this expiry @@ -184,29 +192,61 @@ export function assembleSurfaceFromCache( ? { delta, gamma, theta, vega, vanna: null } : null, }); - if (typeof r.ts === 'string' && r.ts > latestTs) latestTs = r.ts; + if (typeof r.ts === 'string' && (!newestChain || r.ts > newestChain)) newestChain = r.ts; } } if (rows.length === 0) return null; - const asOf = opts.asOf ?? latestTs ?? new Date().toISOString(); + const spotHit = spotAt(db, symbol, asOf, quote); return { symbol, - spot: quote.price, + spot: spotHit.price, asOf, + quoteAsOf: spotHit.asOf, + chainAsOf: newestChain, providerId: providerId === 'synthetic' ? 'synthetic' : providerId, delaySeconds: meta.delaySeconds, oiFreshness: meta.oiFreshness, rows, provenance: { - fetchedAt: asOf, + fetchedAt: newestChain ?? quote.observed_at, sourceKind: 'yfinance', rawSourceId: `options-surface:${providerId}:${symbol}`, }, }; } +/** Same-print window: quote and book are one snapshot if within this. */ +export const SPOT_ALIGN_MS = 45 * 60_000; + +/** Spot at a book timestamp: live quote if aligned, else last candle at or before `atIso`. */ +export function spotAt( + db: DatabaseSync, + symbol: string, + atIso: string, + quote: { price: number; observed_at: string }, +): { price: number; asOf: string; source: 'quote' | 'candle' } { + const at = Date.parse(atIso); + const qAt = Date.parse(quote.observed_at); + if (Number.isFinite(at) && Number.isFinite(qAt) && Math.abs(qAt - at) <= SPOT_ALIGN_MS) { + return { price: quote.price, asOf: quote.observed_at, source: 'quote' }; + } + try { + const row = db.prepare( + `SELECT ts, c FROM price_candles + WHERE symbol=? AND timeframe IN ('1m','5m','1d') AND ts<=? + ORDER BY ts DESC LIMIT 1`, + ).get(symbol, atIso) as { ts: string; c: number } | undefined; + if (row && Number.isFinite(row.c) && row.c > 0) { + return { price: row.c, asOf: row.ts, source: 'candle' }; + } + } catch { + /* tests without price_candles fall through */ + } + return { price: quote.price, asOf: quote.observed_at, source: 'quote' }; +} + function num(v: unknown): number | null { return typeof v === 'number' && Number.isFinite(v) ? v : null; } diff --git a/app/server/src/options/__tests__/OptionsChainRouter.test.ts b/app/server/src/options/__tests__/OptionsChainRouter.test.ts index 1df2e4a..20d6501 100644 --- a/app/server/src/options/__tests__/OptionsChainRouter.test.ts +++ b/app/server/src/options/__tests__/OptionsChainRouter.test.ts @@ -22,6 +22,10 @@ function memDb(): DatabaseSync { open_interest INTEGER, volume INTEGER, ts TEXT, PRIMARY KEY (symbol, expiry, strike, type, ts) ); + CREATE TABLE price_candles ( + symbol TEXT, timeframe TEXT, ts TEXT, o REAL, h REAL, l REAL, c REAL, v REAL, + adj_close REAL, observed_at TEXT + ); `); return db; } @@ -78,6 +82,33 @@ test('assembleSurfaceFromCache builds rows from latest chain snapshot', () => { const call = surface!.rows.find((r) => r.right === 'call'); assert.equal(call!.openInterest, 200); // latest snapshot assert.equal(call!.greeks?.gamma, 0.02); + assert.equal(surface!.quoteAsOf, '2026-08-06T14:00:00.000Z'); + assert.equal(surface!.chainAsOf, '2026-08-06T14:00:00.000Z'); +}); + +test('assembleSurfaceFromCache uses candle spot when quote is from another session', () => { + const db = memDb(); + db.prepare('INSERT INTO quotes (symbol,price,observed_at) VALUES (?,?,?)').run( + 'SPY', 510, '2026-08-16T17:00:00.000Z', + ); + db.prepare('INSERT INTO kv_cache (key,value,observed_at) VALUES (?,?,?)').run( + 'options_expiry:SPY', JSON.stringify(['2026-08-21']), '2026-08-14T20:00:00.000Z', + ); + db.prepare( + `INSERT INTO options_chains (symbol,expiry,strike,type,bid,ask,iv,delta,gamma,theta,vega,open_interest,volume,ts) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + ).run('SPY', '2026-08-21', 500, 'call', 1, 1.1, 0.2, 0.5, 0.01, -0.1, 0.2, 100, 10, '2026-08-14T20:00:00.000Z'); + db.prepare( + `INSERT INTO price_candles (symbol,timeframe,ts,o,h,l,c,v,adj_close,observed_at) + VALUES (?,?,?,?,?,?,?,?,?,?)`, + ).run('SPY', '1d', '2026-08-14T20:00:00.000Z', 500, 502, 498, 499.25, 1, 499.25, '2026-08-14T20:00:00.000Z'); + + const surface = assembleSurfaceFromCache(db, 'SPY', { maxExpiries: 2 }); + assert.ok(surface); + assert.equal(surface!.asOf, '2026-08-14T20:00:00.000Z'); + assert.equal(surface!.spot, 499.25); + assert.equal(surface!.chainAsOf, '2026-08-14T20:00:00.000Z'); + assert.equal(surface!.quoteAsOf, '2026-08-14T20:00:00.000Z'); }); test('composeYFinanceWithOptions routes chain kinds to options adapter', async () => { diff --git a/app/server/src/options/types.ts b/app/server/src/options/types.ts index c0b4875..f3880ab 100644 --- a/app/server/src/options/types.ts +++ b/app/server/src/options/types.ts @@ -46,7 +46,12 @@ export interface NormalizedOptionRow { export interface NormalizedOptionSurface { symbol: string; spot: number; + /** Evaluation clock (spot / time-to-expiry). May be newer than the option book. */ asOf: string; + /** When the spot print was observed. */ + quoteAsOf?: string | null; + /** When the option chain snapshot used for OI/IV was written. */ + chainAsOf?: string | null; providerId: OptionsProviderId; /** Expected quote delay in seconds (900 delayed, 0 realtime, null unknown). */ delaySeconds: number | null; diff --git a/app/server/src/queue/AdapterQueue.ts b/app/server/src/queue/AdapterQueue.ts index b89bde1..75ef693 100644 --- a/app/server/src/queue/AdapterQueue.ts +++ b/app/server/src/queue/AdapterQueue.ts @@ -23,6 +23,7 @@ import { DEFAULT_SOURCE_MIN_INTERVAL_MS, DEMAND_SET_SOFT_CAP, DRAIN_KIND_BUDGET, + fetchTimeoutMs, isPermanentDataError, isRateLimitError, jobBackoffMs, @@ -30,6 +31,8 @@ import { MAX_JOBS_PER_DRAIN, parseCooldownUntil, rateLimitCooldownMs, + STALE_IN_FLIGHT_MS, + YF_STALE_IN_FLIGHT_MS, SCHEDULE_INTERVALS, sourceCooldownHitsKey, sourceCooldownStateKey, @@ -38,8 +41,11 @@ import { } from './sourceRatePolicy.ts'; import { assertSourcesBound, + clearVendorRateLimit, familyDrainBudget, + getVendorCooldownState, isVendorCoolingDown, + listRegisteredFamilies, noteVendorRateLimit, sourceToFamily, sourcesForFamily, @@ -118,6 +124,10 @@ export class AdapterQueue implements CacheScheduler { private _lastError: string | null = null; /** Per-source promise chains: serializes fetches of the same source across parallel drains. */ private readonly _sourceChains = new Map>(); + /** Header / page-view quotes jump ahead of the rest of the Yahoo pile. */ + private readonly _priorityKeys = new Set(); + /** Overlapping setInterval drains must not select a second batch mid-fetch. */ + private _drainBusy = false; constructor(opts: AdapterQueueOptions) { this._db = opts.db; @@ -308,6 +318,13 @@ export class AdapterQueue implements CacheScheduler { /** Operator / test: force clear cool-down for a source. */ clearSourceCooldownManual(source: SourceKind | string): void { this.clearSourceCooldown(source); + const family = sourceToFamily(String(source)); + if (family) { + clearVendorRateLimit(family); + for (const src of sourcesForFamily(family)) { + this.clearSourceCooldown(src); + } + } } isSymbolQuarantined(symbol: string, now = Date.now()): boolean { @@ -419,15 +436,36 @@ export class AdapterQueue implements CacheScheduler { this._db.prepare('INSERT OR REPLACE INTO adapter_queue (key,status,last_attempt,retry_count,backoff_until,error,scheduled_for) VALUES (?,?,?,?,?,?,?)').run(key, 'pending', null, 0, null, null, scheduledFor ?? null); } + /** Mark a job as next on this source (header symbol / page view). */ + async prioritize(key: CacheKey): Promise { + await this.queue(key); + this._priorityKeys.add(key); + } + async drain(): Promise { if (!this._cache) return; if (this.isPaused()) return; + if (this._drainBusy) return; + this._drainBusy = true; + try { + await this.drainOnce(); + } finally { + this._drainBusy = false; + } + } + + private async drainOnce(): Promise { const now = Date.now(); // Recover hung workers: jobs left in_flight after crash/hang never complete. - const stuckCutoff = new Date(now - 5 * 60_000).toISOString(); + // Yahoo unsticks after 45s (fetch timeout 30s). SEC 13F jobs may run 10 min. + const yfStuckCutoff = new Date(now - YF_STALE_IN_FLIGHT_MS).toISOString(); this._db.prepare( - "UPDATE adapter_queue SET status='pending', error='recovered stale in_flight', retry_count=0 WHERE status='in_flight' AND (last_attempt IS NULL OR last_attempt < ?)", + "UPDATE adapter_queue SET status='pending', error='recovered stale in_flight', retry_count=0 WHERE status='in_flight' AND key LIKE 'yfinance:%' AND (last_attempt IS NULL OR last_attempt < ?)", + ).run(yfStuckCutoff); + const stuckCutoff = new Date(now - STALE_IN_FLIGHT_MS).toISOString(); + this._db.prepare( + "UPDATE adapter_queue SET status='pending', error='recovered stale in_flight', retry_count=0 WHERE status='in_flight' AND key NOT LIKE 'yfinance:%' AND (last_attempt IS NULL OR last_attempt < ?)", ).run(stuckCutoff); // Prefer live marks (quote/candles) over secondary data so watchlist prices land first. @@ -445,24 +483,35 @@ export class AdapterQueue implements CacheScheduler { const nextColon = rest.indexOf(':'); return (nextColon > 0 ? rest.slice(0, nextColon) : rest).toUpperCase(); }; - /** Kind rank: quote=0, candles=1, symbol=2, topHoldings=3, adjustments=4, else=5 */ + const isMinuteCandle = (key: string): boolean => + /^yfinance:candles:[^:]+:(1m|5m)$/.test(key); + const isOptionsSurface = (key: string): boolean => + /^yfinance:(chain|expiry_dates):/.test(key); + /** Kind rank: quote=0, live 1m/5m=1, options chain=2, X=3, 13F=4, daily candles=5, … */ const kindRank = (key: string): number => { if (key.startsWith('yfinance:quote:')) return 0; - if (key.startsWith('yfinance:candles:')) return 1; - if (key.startsWith('yfinance:symbol:')) return 2; - if (key.startsWith('yfinance:topHoldings:')) return 3; - if (key.startsWith('yfinance:adjustments:')) return 4; - return 5; + if (isMinuteCandle(key)) return 1; + if (isOptionsSurface(key)) return 2; + if (key.startsWith('x:timeline:')) return 3; + if (key.startsWith('sec-fetch:fetch:')) return 4; + if (key.startsWith('yfinance:candles:')) return 5; + if (key.startsWith('yfinance:symbol:')) return 6; + if (key.startsWith('yfinance:topHoldings:')) return 7; + if (key.startsWith('yfinance:adjustments:')) return 8; + return 9; }; const jobs = this._db.prepare( `SELECT key, retry_count, backoff_until, scheduled_for, last_attempt FROM adapter_queue WHERE status IN ('pending','backoff')`, ).all() as Array<{ key: string; retry_count: number; backoff_until?: string | null; scheduled_for?: string | null; last_attempt?: string | null }>; - // Tier-aware sort: portfolio symbols (T0) first, then T1, T2, T3. + // Tier-aware sort: jumped header quotes first, then portfolio (T0), T1, T2, T3. // Within each tier: quote > candles > symbol > topHoldings > adjustments > other. // Within each kind: never-attempted first, then oldest first. jobs.sort((a, b) => { + const pa = this._priorityKeys.has(a.key) ? 0 : 1; + const pb = this._priorityKeys.has(b.key) ? 0 : 1; + if (pa !== pb) return pa - pb; const ta = tierMap.get(symFromKey(a.key)) ?? 99; const tb = tierMap.get(symFromKey(b.key)) ?? 99; if (ta !== tb) return ta - tb; @@ -484,10 +533,37 @@ export class AdapterQueue implements CacheScheduler { const pendingQuotes = this._db.prepare( "SELECT COUNT(*) AS c FROM adapter_queue WHERE status='pending' AND key LIKE 'yfinance:quote:%'", ).get() as { c: number }; + const demandSize = (this._db.prepare( + 'SELECT COUNT(*) AS c FROM symbol_demand WHERE in_demand=1 OR COALESCE(system_pin,0)=1', + ).get() as { c: number }).c; const backlogPressure = pendingQuotes.c > 25; + const hotQuotePending = (this._db.prepare( + `SELECT COUNT(*) AS c FROM adapter_queue q + WHERE q.status='pending' AND q.key LIKE 'yfinance:quote:%' + AND COALESCE((SELECT tier FROM symbol_demand d WHERE d.symbol = substr(q.key, 16)), 99) < 3`, + ).get() as { c: number }).c > 0; + + // Soft cap is real under pressure: drop T3 Yahoo work so it cannot starve holdings. + if (backlogPressure || demandSize > DEMAND_SET_SOFT_CAP) { + const droppable = jobs.filter((j) => { + if (!j.key.startsWith('yfinance:')) return false; + if (this._priorityKeys.has(j.key)) return false; + const sym = symFromKey(j.key); + return (tierMap.get(sym) ?? 99) >= 3; + }); + if (droppable.length > 0) { + const dropped = new Set(droppable.map((j) => j.key)); + const del = this._db.prepare("DELETE FROM adapter_queue WHERE key=? AND status IN ('pending','backoff')"); + for (const j of droppable) del.run(j.key); + for (let i = jobs.length - 1; i >= 0; i--) { + if (dropped.has(jobs[i].key)) jobs.splice(i, 1); + } + } + } + const NON_CRITICAL_YF_KINDS = new Set([ - 'chain', 'shortinterest', 'dividendFundamentals', - 'topHoldings', 'expiry_dates', 'symbol', 'adjustments', + 'shortinterest', 'dividendFundamentals', + 'topHoldings', 'symbol', 'adjustments', ]); let processed = 0; @@ -545,12 +621,19 @@ export class AdapterQueue implements CacheScheduler { // below drops them because their "sym" is a fund handle, not a symbol. const isXTimeline = source === 'x' && kind === 'timeline'; + const tier = tierMap.get(sym) ?? 99; + const isHotQuote = source === 'yfinance' && kind === 'quote' && tier < 3; + const isHotMinute = source === 'yfinance' && isMinuteCandle(job.key) && tier < 3; + // While a holding / watched / focused quote is waiting, Yahoo only fetches + // those live marks. Chains and T3 names cannot jump the line. + if (hotQuotePending && source === 'yfinance' && !isHotQuote && !isHotMinute) continue; // Backlog pressure: defer non-critical yfinance kinds so quotes drain first. if (backlogPressure && source === 'yfinance' && NON_CRITICAL_YF_KINDS.has(kind)) continue; // Backlog pressure: skip T3 (background) symbols entirely so portfolio/alert symbols drain first. // x timeline jobs are exempt: their sym is a fund handle (never in tierMap), so // without this they'd always be dropped while the yfinance backlog persists. - if (backlogPressure && sym && (tierMap.get(sym) ?? 99) >= 3 && !isXTimeline) continue; + if ((hotQuotePending || backlogPressure) && source === 'yfinance' && tier >= 3) continue; + if (backlogPressure && sym && tier >= 3 && !isXTimeline && source !== 'yfinance') continue; // Source-wide cool-down: skip all jobs for this vendor until the window ends. if (this.isSourceCoolingDown(source, Date.now())) continue; @@ -562,7 +645,9 @@ export class AdapterQueue implements CacheScheduler { if (family) { const famUsed = familyJobsThisDrain[family] ?? 0; - if (famUsed >= familyDrainBudget(family)) { + // Minute candles and options chains are live map/chart clicks: do not + // park them behind the daily Yahoo quote backlog. + if (famUsed >= familyDrainBudget(family) && !isMinuteCandle(job.key) && !isOptionsSurface(job.key)) { // Book for pass 2 instead of dropping: a fresh subscribe (6 yfinance // jobs) must be able to complete in one drain when there's room. overBudget.push({ job, source, kind, sym, family }); @@ -571,6 +656,7 @@ export class AdapterQueue implements CacheScheduler { } specs.push({ key: job.key, source, family, sym, attempt: job.retry_count + 1 }); + this._priorityKeys.delete(job.key); processed += 1; kindUsed[kind] = used + 1; if (family) familyJobsThisDrain[family] = (familyJobsThisDrain[family] ?? 0) + 1; @@ -588,11 +674,17 @@ export class AdapterQueue implements CacheScheduler { if (family && this.isVendorFamilyCoolingDown(family, Date.now())) continue; const kindBudget = DRAIN_KIND_BUDGET[kind] ?? DRAIN_KIND_BUDGET._default; if ((kindUsed[kind] ?? 0) >= kindBudget) continue; + const tier = tierMap.get(sym) ?? 99; + const isHotQuote = source === 'yfinance' && kind === 'quote' && tier < 3; + const isHotMinute = source === 'yfinance' && isMinuteCandle(job.key) && tier < 3; + if (hotQuotePending && source === 'yfinance' && !isHotQuote && !isHotMinute) continue; // Backlog pressure: defer non-critical yfinance kinds so quotes drain first. if (backlogPressure && source === 'yfinance' && NON_CRITICAL_YF_KINDS.has(kind)) continue; // Backlog pressure: skip T3 (background) symbols entirely so portfolio/alert symbols drain first. - if (backlogPressure && sym && (tierMap.get(sym) ?? 99) >= 3) continue; + if ((hotQuotePending || backlogPressure) && source === 'yfinance' && tier >= 3) continue; + if (backlogPressure && sym && tier >= 3) continue; specs.push({ key: job.key, source, family, sym, attempt: job.retry_count + 1 }); + this._priorityKeys.delete(job.key); processed += 1; kindUsed[kind] = (kindUsed[kind] ?? 0) + 1; } @@ -658,7 +750,7 @@ export class AdapterQueue implements CacheScheduler { this._lastFetchAt[source] = Date.now(); this._setStatus(key, 'in_flight'); try { - const FETCH_TIMEOUT_MS = 30_000; + const FETCH_TIMEOUT_MS = fetchTimeoutMs(key); const fetchPromise = adapter.fetchOne(key); const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error(`fetchOne timeout after ${FETCH_TIMEOUT_MS}ms for ${key}`)), FETCH_TIMEOUT_MS), @@ -1227,10 +1319,29 @@ this._db.prepare("UPDATE adapter_queue SET status='backoff', last_attempt=?, ret } catch { /* ignore */ } const cools = this.listSourceCooldowns(); + const bySource = new Map(cools.map((c) => [c.source, c])); + for (const family of listRegisteredFamilies()) { + const gate = getVendorCooldownState(family); + if (!gate.active) continue; + for (const src of sourcesForFamily(family)) { + const existing = bySource.get(src); + if (!existing || gate.remainingMs > existing.remainingMs) { + const snap: SourceCooldownSnapshot = { + source: src, + until: gate.until, + remainingMs: gate.remainingMs, + consecutiveHits: Math.max(existing?.consecutiveHits ?? 0, gate.consecutiveHits), + active: true, + }; + bySource.set(src, snap); + } + } + } + const mergedCools = [...bySource.values()].filter((c) => c.active); const controlled = this.listControlledSources(); const notes: string[] = []; if (paused) notes.push('queue paused'); - for (const c of cools) { + for (const c of mergedCools) { notes.push(`${c.source} cooling ${Math.ceil(c.remainingMs / 60_000)}m (hits=${c.consecutiveHits})`); } for (const c of controlled) { @@ -1241,8 +1352,10 @@ this._db.prepare("UPDATE adapter_queue SET status='backoff', last_attempt=?, ret if (candleLagMs != null && candleLagMs > 3 * 86_400_000) notes.push(`SPY candle lag ${Math.round(candleLagMs / 86_400_000)}d`); if (demandSize > DEMAND_SET_SOFT_CAP) notes.push(`demand set ${demandSize} > soft cap ${DEMAND_SET_SOFT_CAP}`); - const yfCool = cools.some((c) => c.source === 'yfinance' && c.active); - const dataPlaneHealthy = !paused && !yfCool && (row.q ?? 0) < 150 && (candleLagMs == null || candleLagMs < 3 * 86_400_000); + const yfCool = mergedCools.some((c) => c.source === 'yfinance' && c.active); + const pendingQuotes = pendingByKind.quote ?? 0; + if (pendingQuotes > 25) notes.push(`pending quotes=${pendingQuotes}`); + const dataPlaneHealthy = !paused && !yfCool && (row.q ?? 0) < 150 && pendingQuotes < 25 && (candleLagMs == null || candleLagMs < 3 * 86_400_000); return { queued: row.q ?? 0, @@ -1255,7 +1368,7 @@ this._db.prepare("UPDATE adapter_queue SET status='backoff', last_attempt=?, ret paused, counts: { pending: row.q ?? 0, in_flight: row.i ?? 0, failed: row.f ?? 0, backoff: row.b ?? 0, done: row.d ?? 0 }, lastErrors: failedJobs, - sourceCooldowns: cools, + sourceCooldowns: mergedCools, pausedSources: controlled.filter((c) => c.paused).map((c) => c.source), stoppedSources: controlled.filter((c) => c.stopped).map((c) => c.source), pendingByKind, diff --git a/app/server/src/queue/__tests__/AdapterQueue.test.ts b/app/server/src/queue/__tests__/AdapterQueue.test.ts index fe842da..2905ab9 100644 --- a/app/server/src/queue/__tests__/AdapterQueue.test.ts +++ b/app/server/src/queue/__tests__/AdapterQueue.test.ts @@ -80,6 +80,62 @@ test('after backoff expires, retries; eventually FAILED at MAX_ATTEMPTS', async assert.fail('did not reach failed within 10 drain cycles'); }); +test('drain prefers 5m candles over a pile of daily candle jobs', async () => { + const { db, fake, queue } = setup(); + const bar = { ts: '2026-08-15T14:30:00.000Z', o: 1, h: 2, l: 1, c: 1.5, v: 10, adjClose: 1.5 }; + fake.set('yfinance:candles:NVDA:5m', [bar], 'daily_permanent'); + for (let i = 0; i < 30; i++) { + const sym = `S${i}`; + fake.set(`yfinance:candles:${sym}:1d`, [bar], 'daily_permanent'); + await queue.queue(`yfinance:candles:${sym}:1d`); + } + await queue.queue('yfinance:candles:NVDA:5m'); + await queue.drain(); + assert.equal(statusOf(db, 'yfinance:candles:NVDA:5m').status, 'done'); + assert.ok(fake.calls.includes('yfinance:candles:NVDA:5m')); +}); + +test('drain prefers x timeline over a pile of daily candle jobs', async () => { + resetVendorGateForTests(); + const db = createDb({ path: ':memory:' }); + initSchema(db); + const bar = { ts: '2026-08-15T14:30:00.000Z', o: 1, h: 2, l: 1, c: 1.5, v: 10, adjClose: 1.5 }; + const yf = new FakeSourceAdapter('yfinance'); + const x = new FakeSourceAdapter('x'); + x.set('x:timeline:TheProfInvestor', [], 'thread_7d'); + for (let i = 0; i < 30; i++) { + const sym = `S${i}`; + yf.set(`yfinance:candles:${sym}:1d`, [bar], 'daily_permanent'); + } + const adapters = new Map([ + ['yfinance' as const, yf as unknown as import('../../adapters/SourceAdapter.ts').SourceFetch], + ['x' as const, x as unknown as import('../../adapters/SourceAdapter.ts').SourceFetch], + ]); + const queue = new AdapterQueue({ db, adapters, rateLimitMs: { yfinance: 0, x: 0 } }); + const cache = createCacheRepository({ db, scheduler: queue }); + queue.cache = cache; + for (let i = 0; i < 30; i++) await queue.queue(`yfinance:candles:S${i}:1d`); + await queue.queue('x:timeline:TheProfInvestor'); + await queue.drain(); + assert.equal(statusOf(db, 'x:timeline:TheProfInvestor').status, 'done'); + assert.ok(x.calls.includes('x:timeline:TheProfInvestor')); +}); + +test('drain prefers options chain over a pile of daily candle jobs', async () => { + const { db, fake, queue } = setup(); + const bar = { ts: '2026-08-15T14:30:00.000Z', o: 1, h: 2, l: 1, c: 1.5, v: 10, adjClose: 1.5 }; + fake.set('yfinance:chain:IREN:2026-08-21', { rows: [] }, 'options_snapshot'); + for (let i = 0; i < 30; i++) { + const sym = `S${i}`; + fake.set(`yfinance:candles:${sym}:1d`, [bar], 'daily_permanent'); + await queue.queue(`yfinance:candles:${sym}:1d`); + } + await queue.queue('yfinance:chain:IREN:2026-08-21'); + await queue.drain(); + assert.equal(statusOf(db, 'yfinance:chain:IREN:2026-08-21').status, 'done'); + assert.ok(fake.calls.includes('yfinance:chain:IREN:2026-08-21')); +}); + test('health() reports queued/in_flight/backoff counts', async () => { const { db, queue } = setup(); await queue.queue('yfinance:quote:NVDA'); @@ -309,6 +365,85 @@ test('stopped source schedules skip enqueue; startSource forces next_enqueue to assert.ok(Date.parse(row.next_enqueue) <= Date.now() + 5000); }); +function seedDemand( + db: import('node:sqlite').DatabaseSync, + symbol: string, + tier: number, +): void { + db.prepare( + 'INSERT OR REPLACE INTO symbol_demand (symbol,refcount,ticker_kind,in_demand,last_refreshed_at,system_pin,tier) VALUES (?,?,?,?,?,?,?)', + ).run(symbol, 1, 'equity', 1, null, 0, tier); +} + +test('drain fetches T0 quote before any T3 Yahoo job', async () => { + const { db, fake, queue } = setup(); + fake.set('yfinance:quote:IREN', { symbol: 'IREN', price: 45 } as Quote, 'live_quote'); + fake.set('yfinance:quote:INTC', { symbol: 'INTC', price: 100 } as Quote, 'live_quote'); + seedDemand(db, 'IREN', 0); + seedDemand(db, 'INTC', 3); + await queue.queue('yfinance:quote:INTC'); + await queue.queue('yfinance:quote:IREN'); + await queue.drain(); + assert.equal(statusOf(db, 'yfinance:quote:IREN').status, 'done'); + assert.equal(statusOf(db, 'yfinance:quote:INTC').status, 'pending', 'T3 must wait while a hot quote is pending'); + assert.deepEqual(fake.calls, ['yfinance:quote:IREN']); + await queue.drain(); + assert.equal(statusOf(db, 'yfinance:quote:INTC').status, 'done'); +}); + +test('prioritizeQuote jumps ahead of other pending hot quotes', async () => { + const { db, fake, queue } = setup(); + fake.set('yfinance:quote:IREN', { symbol: 'IREN', price: 45 } as Quote, 'live_quote'); + fake.set('yfinance:quote:NVDA', { symbol: 'NVDA', price: 194 } as Quote, 'live_quote'); + seedDemand(db, 'IREN', 2); + seedDemand(db, 'NVDA', 2); + await queue.queue('yfinance:quote:NVDA'); + await queue.prioritize('yfinance:quote:IREN'); + await queue.drain(); + assert.equal(fake.calls[0], 'yfinance:quote:IREN'); +}); + +test('overlapping drain() does not double-select the same Yahoo job', async () => { + const { db, queue } = setup(); + let inflight = 0; + let maxInflight = 0; + const slow = { + sourceKind: 'yfinance' as const, + async fetchOne(key: string) { + inflight += 1; + maxInflight = Math.max(maxInflight, inflight); + await new Promise((r) => setTimeout(r, 40)); + inflight -= 1; + return { + value: { symbol: 'NVDA', price: 1 }, + ttlClass: 'live_quote' as const, + provenance: { fetchedAt: new Date().toISOString(), sourceKind: 'yfinance' as const }, + }; + }, + }; + const { AdapterQueue: AQ } = await import('../AdapterQueue.ts'); + const q2 = new AQ({ + db, + adapters: new Map([['yfinance' as const, slow as import('../../adapters/SourceAdapter.ts').SourceFetch]]), + rateLimitMs: { yfinance: 0 }, + }); + q2.cache = (await import('../../cache/CacheRepository.ts')).createCacheRepository({ db, scheduler: q2 }); + await q2.queue('yfinance:quote:NVDA'); + await Promise.all([q2.drain(), q2.drain()]); + assert.equal(maxInflight, 1); + assert.equal(statusOf(db, 'yfinance:quote:NVDA').status, 'done'); +}); + +test('health() merges vendorGate into sourceCooldowns when queue_state is empty', async () => { + const { queue } = setup(); + const { noteVendorRateLimit } = await import('../../services/vendorGate.ts'); + noteVendorRateLimit('yfinance'); + const h = queue.health(); + assert.ok(h.sourceCooldowns.some((c) => c.source === 'yfinance' && c.active)); + assert.ok(h.dataPlaneNotes.some((n) => /yfinance/.test(n) && /cool/.test(n))); + assert.equal(h.dataPlaneHealthy, false); +}); + test('health() reports pausedSources and stoppedSources', async () => { const { queue } = setup(); queue.pauseSource('fred'); diff --git a/app/server/src/queue/__tests__/sourceRatePolicy.test.ts b/app/server/src/queue/__tests__/sourceRatePolicy.test.ts index 8bfd0b1..df8e74d 100644 --- a/app/server/src/queue/__tests__/sourceRatePolicy.test.ts +++ b/app/server/src/queue/__tests__/sourceRatePolicy.test.ts @@ -1,6 +1,8 @@ import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { + DEFAULT_FETCH_TIMEOUT_MS, + fetchTimeoutMs, isPermanentDataError, isRateLimitError, isUsMarketHours, @@ -9,6 +11,8 @@ import { quoteTtlMs, rateLimitCooldownMs, RATE_LIMIT_COOLDOWN_MS, + SEC_FETCH_TIMEOUT_MS, + STALE_IN_FLIGHT_MS, } from '../sourceRatePolicy.ts'; test('isRateLimitError detects Yahoo Edge / 429 / bird HTTP 429', () => { @@ -78,6 +82,15 @@ test('jobBackoffMs stays in short window for ordinary errors', () => { assert.ok(jobBackoffMs(1) < rateLimitCooldownMs(1)); }); +test('fetchTimeoutMs gives SEC institutional jobs minutes, not 30s', () => { + assert.equal(fetchTimeoutMs('yfinance:quote:AAPL'), DEFAULT_FETCH_TIMEOUT_MS); + assert.equal(fetchTimeoutMs('sec-fetch:fetch:IREN'), SEC_FETCH_TIMEOUT_MS); + assert.equal(fetchTimeoutMs('sec-sc-fetch:sc:CIFR'), SEC_FETCH_TIMEOUT_MS); + assert.equal(fetchTimeoutMs('sec-lint-holders:holders:OPEN'), SEC_FETCH_TIMEOUT_MS); + assert.ok(SEC_FETCH_TIMEOUT_MS >= 10 * 60_000); + assert.ok(STALE_IN_FLIGHT_MS > SEC_FETCH_TIMEOUT_MS); +}); + test('parseCooldownUntil reports active remaining window', () => { const now = Date.UTC(2026, 0, 1, 12, 0, 0); const until = new Date(now + 30_000).toISOString(); diff --git a/app/server/src/queue/sourceRatePolicy.ts b/app/server/src/queue/sourceRatePolicy.ts index 43a156e..ddcb264 100644 --- a/app/server/src/queue/sourceRatePolicy.ts +++ b/app/server/src/queue/sourceRatePolicy.ts @@ -63,7 +63,7 @@ export const DRAIN_KIND_BUDGET: Record = { topHoldings: 1, expiry_dates: 2, chain: 2, - fetch: 1, + fetch: 2, holders: 1, insiders: 1, timeline: 1, @@ -73,6 +73,33 @@ export const DRAIN_KIND_BUDGET: Record = { /** Max jobs processed in one drain() call across all sources. */ export const MAX_JOBS_PER_DRAIN = 25; +/** Default fetchOne timeout (quotes, meta, light jobs). */ +export const DEFAULT_FETCH_TIMEOUT_MS = 30_000; + +/** + * 13F holder refresh downloads dozens of EDGAR XMLs plus a reverse-13F + * supplement. 30s kills the job mid-run (then the work keeps hitting SEC). + */ +export const SEC_FETCH_TIMEOUT_MS = 10 * 60_000; + +/** + * Drain recovers hung in_flight rows. Must exceed the longest fetch timeout + * or a live 13F job is reset and started a second time. + */ +export const STALE_IN_FLIGHT_MS = 12 * 60_000; + +/** Yahoo fetch timeout is 30s; do not wait the SEC-sized 12m window to unstick a hung quote. */ +export const YF_STALE_IN_FLIGHT_MS = 45_000; + +/** Per-job fetch timeout. SEC institutional jobs need minutes, not seconds. */ +export function fetchTimeoutMs(key: string): number { + const source = key.split(':')[0] ?? ''; + if (source === 'sec-fetch' || source === 'sec-sc-fetch' || source.startsWith('sec-lint')) { + return SEC_FETCH_TIMEOUT_MS; + } + return DEFAULT_FETCH_TIMEOUT_MS; +} + /** Soft cap on demand-set size; schedule skips excess beyond system pins. */ export const DEMAND_SET_SOFT_CAP = 80; diff --git a/app/server/src/services/__tests__/captureEvidence.test.ts b/app/server/src/services/__tests__/captureEvidence.test.ts new file mode 100644 index 0000000..5552a31 --- /dev/null +++ b/app/server/src/services/__tests__/captureEvidence.test.ts @@ -0,0 +1,55 @@ +import { test } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { DatabaseSync } from 'node:sqlite'; +import { + annotateCaptureEvidence, + evidencePostId, + isTweetGoneMessage, +} from '../captureEvidence.ts'; + +test('evidencePostId extracts status id from x.com URL', () => { + assert.equal( + evidencePostId('https://x.com/mikealfred/status/2082901502113575384'), + '2082901502113575384', + ); + assert.equal(evidencePostId('sec|0002096493-26-000003'), null); +}); + +test('isTweetGoneMessage matches bird / X not-found errors', () => { + assert.equal(isTweetGoneMessage('Failed to read tweet: Tweet not found in response'), true); + assert.equal(isTweetGoneMessage('HTTP 429 rate limit'), false); +}); + +test('annotateCaptureEvidence uses cache and only looks up misses', async () => { + const db = new DatabaseSync(':memory:'); + db.exec(`CREATE TABLE kv_cache (key TEXT PRIMARY KEY, value TEXT NOT NULL, observed_at TEXT NOT NULL)`); + const now = Date.parse('2026-08-14T20:00:00.000Z'); + db.prepare('INSERT INTO kv_cache (key,value,observed_at) VALUES (?,?,?)').run( + 'x:post-status:111', 'ok', new Date(now).toISOString(), + ); + const looked: string[] = []; + const rows = [ + { evidence_url: 'https://x.com/mikealfred/status/111' }, + { evidence_url: 'https://x.com/mikealfred/status/2082901502113575384' }, + ]; + await annotateCaptureEvidence(db, rows, async (id) => { + looked.push(id); + return 'deleted'; + }, now); + assert.deepEqual(looked, ['2082901502113575384']); + assert.equal(rows[0].evidence_status, 'ok'); + assert.equal(rows[1].evidence_status, 'deleted'); +}); + +test('annotateCaptureEvidence live:false never calls lookup', async () => { + const db = new DatabaseSync(':memory:'); + db.exec(`CREATE TABLE kv_cache (key TEXT PRIMARY KEY, value TEXT NOT NULL, observed_at TEXT NOT NULL)`); + const looked: string[] = []; + const rows = [{ evidence_url: 'https://x.com/mikealfred/status/2082901502113575384' }]; + await annotateCaptureEvidence(db, rows, async (id) => { + looked.push(id); + return 'ok'; + }, Date.now(), { live: false }); + assert.deepEqual(looked, []); + assert.equal(rows[0].evidence_status, 'unknown'); +}); diff --git a/app/server/src/services/__tests__/captureIngest.test.ts b/app/server/src/services/__tests__/captureIngest.test.ts index 41f73e3..1121136 100644 --- a/app/server/src/services/__tests__/captureIngest.test.ts +++ b/app/server/src/services/__tests__/captureIngest.test.ts @@ -19,7 +19,8 @@ function makeDb(): DatabaseSync { ); CREATE TABLE x_cookie_posts ( post_id TEXT PRIMARY KEY, source TEXT, author_handle TEXT, cashtag TEXT, - body_text TEXT, posted_at TEXT, engagement TEXT, cached_until TEXT + body_text TEXT, posted_at TEXT, engagement TEXT, cached_until TEXT, + quoted_post_id TEXT ); `); db.prepare(`INSERT INTO tracked_funds (id, ci_key, fund_name, manager_name, x_handle, paywall_status, enabled, created_at) @@ -28,9 +29,9 @@ function makeDb(): DatabaseSync { return db; } -function seedPost(db: DatabaseSync, id: string, body: string, at: string): void { - db.prepare(`INSERT INTO x_cookie_posts (post_id, source, author_handle, cashtag, body_text, posted_at) - VALUES (?, 'x', 'mikealfred', NULL, ?, ?)`).run(id, body, at); +function seedPost(db: DatabaseSync, id: string, body: string, at: string, quotedPostId?: string): void { + db.prepare(`INSERT INTO x_cookie_posts (post_id, source, author_handle, cashtag, body_text, posted_at, quoted_post_id) + VALUES (?, 'x', 'mikealfred', NULL, ?, ?, ?)`).run(id, body, at, quotedPostId ?? null); } test('ingestFundCaptures: materializes a total-position capture with source=capture', () => { @@ -63,15 +64,17 @@ test('ingestFundCaptures: idempotent — same post converges, no duplicate rows' assert.equal(db.prepare('SELECT COUNT(*) FROM fund_position_records').get()!['COUNT(*)'], 1); }); -test('ingestFundCaptures: commentary posts and claims are never materialized', () => { +test('ingestFundCaptures: commentary is skipped; a clean add folds into the book', () => { const db = makeDb(); seedPost(db, 'c1', 'Beautiful day in the mountains. Great conversations with subscribers.', '2026-08-04T23:07:20.000Z'); seedPost(db, 'cl1', 'Added 865,000 shares of BKKT at $3.10 average price today.', '2026-08-01T10:00:00.000Z'); const s = ingestFundCaptures(db, 'alpine-fox-capital'); - assert.equal(s.skipped, 1); // commentary - assert.equal(s.claims, 1); // claim detected but skipped in v1 - assert.equal(db.prepare('SELECT COUNT(*) FROM fund_position_records').get()!['COUNT(*)'], 0); + assert.equal(s.skipped, 1); + assert.equal(s.inserted, 1); + const bkkt = db.prepare(`SELECT shares, cost_basis FROM fund_position_records WHERE symbol='BKKT'`).get() as any; + assert.equal(bkkt.shares, 865000); + assert.ok(Math.abs(bkkt.cost_basis - 3.1) < 0.001); }); test('ingestFundCaptures: trade narrative with two quantities is refused (no garbage)', () => { @@ -117,6 +120,102 @@ OPEN now over 6.45M shares. Have taken it up on the weakness.`, ); }); +test('ingestFundCaptures: first-disclosure add folds into an opening ENHA row', () => { + const db = makeDb(); + seedPost( + db, + '2088348177665798249', + "Seeing some very strange trading in ENHA. So without even thinking about it, I added 115,000 shares at $1.79. I still believe in my friend @C_Angermayer's vision. Still too early to write this one off.", + '2026-08-14T19:32:59.000Z', + ); + const s = ingestFundCaptures(db, 'alpine-fox-capital'); + assert.equal(s.inserted, 1); + assert.equal(s.captures, 1); + const row = db.prepare(`SELECT * FROM fund_position_records WHERE symbol='ENHA'`).get() as any; + assert.equal(row.shares, 115000); + assert.ok(Math.abs(row.cost_basis - 1.79) < 0.001); + assert.equal(row.as_of, '2026-08-14'); + assert.equal(row.source, 'capture'); +}); + +test('ingestFundCaptures: same add tweet is idempotent', () => { + const db = makeDb(); + seedPost( + db, + '2088348177665798249', + "Seeing some very strange trading in ENHA. So without even thinking about it, I added 115,000 shares at $1.79.", + '2026-08-14T19:32:59.000Z', + ); + ingestFundCaptures(db, 'alpine-fox-capital'); + ingestFundCaptures(db, 'alpine-fox-capital'); + const n = db.prepare(`SELECT COUNT(*) AS n FROM fund_position_records WHERE symbol='ENHA'`).get() as { n: number }; + const row = db.prepare(`SELECT shares FROM fund_position_records WHERE symbol='ENHA'`).get() as { shares: number }; + assert.equal(n.n, 1); + assert.equal(row.shares, 115000); +}); + +test('ingestFundCaptures: second add folds onto the prior total', () => { + const db = makeDb(); + seedPost(db, 'a1', 'Added 100,000 shares of ENHA at $1.80.', '2026-08-10T12:00:00.000Z'); + seedPost(db, 'a2', 'Added 15,000 shares of ENHA at $1.79.', '2026-08-14T19:32:59.000Z'); + ingestFundCaptures(db, 'alpine-fox-capital'); + const latest = db.prepare( + `SELECT shares, as_of FROM fund_position_records WHERE symbol='ENHA' ORDER BY as_of DESC LIMIT 1`, + ).get() as any; + assert.equal(latest.shares, 115000); + assert.equal(latest.as_of, '2026-08-14'); +}); + +test('ingestFundCaptures: quote-tweet add inherits ticker from the quoted capture', () => { + const db = makeDb(); + seedPost( + db, + '2088348177665798249', + "Seeing some very strange trading in ENHA. So without even thinking about it, I added 115,000 shares at $1.79.", + '2026-08-14T19:32:59.000Z', + ); + seedPost( + db, + '2089409857296453731', + "Quick update on this one. I added another 123,000 shares to this today at $1.62 net. Average now $1.69.", + '2026-08-17T17:51:43.000Z', + '2088348177665798249', + ); + ingestFundCaptures(db, 'alpine-fox-capital'); + const latest = db.prepare( + `SELECT shares, cost_basis, as_of FROM fund_position_records WHERE symbol='ENHA' ORDER BY as_of DESC LIMIT 1`, + ).get() as any; + assert.equal(latest.shares, 238000); + assert.ok(Math.abs(latest.cost_basis - 1.69) < 0.001); + assert.equal(latest.as_of, '2026-08-17'); +}); + +test('ingestFundCaptures: sold out of $PEP writes a zero-share capture', () => { + const db = makeDb(); + seedPost(db, 'e1', 'Sold out of $PEP this morning. Moving on.', '2026-08-02T14:00:00.000Z'); + const s = ingestFundCaptures(db, 'alpine-fox-capital'); + assert.equal(s.captures, 1); + const row = db.prepare(`SELECT * FROM fund_position_records WHERE symbol='PEP'`).get() as any; + assert.equal(row.shares, 0); + assert.equal(row.source, 'capture'); + assert.equal(row.as_of, '2026-08-02'); +}); + +test('ingestFundCaptures: book-reset list exits names that were held and not named', () => { + const db = makeDb(); + db.prepare( + `INSERT INTO fund_position_records (id, fund_id, symbol, shares, value_usd, cost_basis, as_of, source, evidence_url, notes, created_at) + VALUES ('r1','alpine-fox-capital','CIFR',100,1,null,'2026-06-30','13f',null,null,'2026-06-30'), + ('r2','alpine-fox-capital','DEO',50,1,null,'2026-06-30','13f',null,null,'2026-06-30')`, + ).run(); + seedPost(db, 'rst', 'Cleaning the book. What stayed: $CIFR $IREN', '2026-07-20T12:00:00.000Z'); + ingestFundCaptures(db, 'alpine-fox-capital'); + const deo = db.prepare(`SELECT * FROM fund_position_records WHERE symbol='DEO' ORDER BY as_of DESC`).all() as any[]; + assert.ok(deo.some((r) => r.shares === 0 && r.source === 'capture')); + const cifrZero = db.prepare(`SELECT COUNT(*) AS n FROM fund_position_records WHERE symbol='CIFR' AND shares=0`).get() as { n: number }; + assert.equal(cifrZero.n, 0, 'named stayed ticker is not exited'); +}); + test('ingestAllFundCaptures: runs across every enabled tracked fund with a handle', () => { const db = makeDb(); seedPost(db, 'p1', '**Real time position update\n\nTook OPEN over 5.7M shares now. Brought average down to $4.42.', '2026-07-30T19:51:12.000Z'); diff --git a/app/server/src/services/__tests__/secCusipCache.test.ts b/app/server/src/services/__tests__/secCusipCache.test.ts index dda0d6d..600c2ab 100644 --- a/app/server/src/services/__tests__/secCusipCache.test.ts +++ b/app/server/src/services/__tests__/secCusipCache.test.ts @@ -30,6 +30,10 @@ test('kv_cache can store and read sec:cusip keys', () => { test('curated map resolves IREN offline', () => { assert.equal(curatedCusipForSymbol('IREN'), 'Q4982L109'); assert.equal(curatedCusipForSymbol('AAPL'), '037833100'); + assert.equal(curatedCusipForSymbol('ANY'), '84841L506'); + assert.equal(curatedCusipForSymbol('SLNH'), '583543301'); + assert.equal(curatedCusipForSymbol('NUAI'), '64428N109'); + assert.equal(curatedCusipForSymbol('IRE'), '88636Y888'); }); test('resolveCusipLocal seeds cache from curated map', () => { diff --git a/app/server/src/services/captureEvidence.ts b/app/server/src/services/captureEvidence.ts new file mode 100644 index 0000000..a8ebee6 --- /dev/null +++ b/app/server/src/services/captureEvidence.ts @@ -0,0 +1,90 @@ +// Probe X evidence URLs for tracked-fund captures. Paywalled posts still +// resolve via cookie bird; "Tweet not found" means deleted / no longer available. + +import type { DatabaseSync } from 'node:sqlite'; + +export type EvidenceStatus = 'ok' | 'deleted' | 'unknown'; + +const STATUS_KEY = (id: string) => `x:post-status:${id}`; +const OK_TTL_MS = 12 * 60 * 60_000; +const DELETED_TTL_MS = 7 * 24 * 60 * 60_000; + +export function evidencePostId(url: string | null | undefined): string | null { + if (!url) return null; + const m = /(?:x\.com|twitter\.com)\/[^/]+\/status\/(\d+)/i.exec(url); + return m?.[1] ?? null; +} + +export function isTweetGoneMessage(msg: string): boolean { + return /tweet not found|post not found|status not found|no status found|does not exist/i.test(msg); +} + +export interface EvidenceRow { + evidence_url: string | null; + evidence_status?: EvidenceStatus; +} + +export async function annotateCaptureEvidence( + db: DatabaseSync, + rows: EvidenceRow[], + lookup: (postId: string) => Promise, + now = Date.now(), + opts: { live?: boolean } = {}, +): Promise { + const live = opts.live !== false; + const ids = [...new Set(rows.map((r) => evidencePostId(r.evidence_url)).filter((x): x is string => !!x))]; + const statusById = new Map(); + + for (const id of ids) { + const cached = readCachedStatus(db, id, now); + if (cached) { + statusById.set(id, cached); + continue; + } + if (!live) { + statusById.set(id, 'unknown'); + continue; + } + try { + const status = await lookup(id); + if (status !== 'unknown') writeCachedStatus(db, id, status, now); + statusById.set(id, status); + } catch { + statusById.set(id, 'unknown'); + } + } + + for (const row of rows) { + const id = evidencePostId(row.evidence_url); + if (!id) continue; + row.evidence_status = statusById.get(id) ?? 'unknown'; + } +} + +function readCachedStatus(db: DatabaseSync, id: string, now: number): EvidenceStatus | null { + try { + const row = db.prepare('SELECT value, observed_at FROM kv_cache WHERE key=?').get(STATUS_KEY(id)) as + | { value: string; observed_at: string } + | undefined; + if (!row) return null; + if (row.value !== 'ok' && row.value !== 'deleted') return null; + const age = now - Date.parse(row.observed_at); + const ttl = row.value === 'deleted' ? DELETED_TTL_MS : OK_TTL_MS; + if (!Number.isFinite(age) || age > ttl) return null; + return row.value; + } catch { + return null; + } +} + +function writeCachedStatus(db: DatabaseSync, id: string, status: EvidenceStatus, now: number): void { + try { + db.prepare('INSERT OR REPLACE INTO kv_cache (key, value, observed_at) VALUES (?,?,?)').run( + STATUS_KEY(id), + status, + new Date(now).toISOString(), + ); + } catch { + /* ignore */ + } +} diff --git a/app/server/src/services/captureIngest.ts b/app/server/src/services/captureIngest.ts index 974d1d0..9536e0f 100644 --- a/app/server/src/services/captureIngest.ts +++ b/app/server/src/services/captureIngest.ts @@ -12,7 +12,12 @@ // Local-only: no network, no rate limiting — this is a materializer, run from // the x schedule branch after timeline jobs are enqueued. import type { DatabaseSync } from 'node:sqlite'; -import { extractCaptures } from '../mirror/captureParser.ts'; +import { + classifyPost, + extractCaptures, + extractRemainingBookTickers, + inferResetExits, +} from '../mirror/captureParser.ts'; export interface CaptureIngestStats { fundId: string; @@ -39,6 +44,7 @@ interface PostRow { post_id: string; body_text: string | null; posted_at: string; + quoted_post_id?: string | null; } /** @@ -65,7 +71,9 @@ export function ingestFundCaptures(db: DatabaseSync, fundId: string): CaptureIng if (fund.enabled !== 1) { stats.disabled = true; return stats; } const posts = db.prepare( - `SELECT post_id, body_text, posted_at FROM x_cookie_posts + `SELECT post_id, body_text, posted_at, + ${hasQuotedPostIdColumn(db) ? 'quoted_post_id' : 'NULL AS quoted_post_id'} + FROM x_cookie_posts WHERE lower(author_handle) = lower(?) AND body_text IS NOT NULL AND body_text != '' ORDER BY posted_at ASC`, @@ -86,7 +94,21 @@ export function ingestFundCaptures(db: DatabaseSync, fundId: string): CaptureIng for (const p of posts) { // One post can mention several names (Mike: SLNH fill + OPEN total in one tweet). - const parsedList = extractCaptures(p.body_text ?? ''); + const parsedList = capturesForPost(db, fund.x_handle, p); + const stayed = extractRemainingBookTickers(p.body_text ?? ''); + if (stayed) { + const held = db.prepare( + `SELECT fpr.symbol FROM fund_position_records fpr + JOIN ( + SELECT symbol, MAX(as_of) AS max_as_of + FROM fund_position_records WHERE fund_id = ? GROUP BY symbol + ) latest ON latest.symbol = fpr.symbol AND latest.max_as_of = fpr.as_of + WHERE fpr.fund_id = ? AND (fpr.shares IS NULL OR fpr.shares > 0)`, + ).all(fundId, fundId) as Array<{ symbol: string }>; + for (const exit of inferResetExits(held.map((h) => h.symbol), stayed)) { + if (!parsedList.some((x) => x.symbol === exit.symbol)) parsedList.push(exit); + } + } if (parsedList.length === 0) { stats.skipped++; continue; @@ -94,11 +116,11 @@ export function ingestFundCaptures(db: DatabaseSync, fundId: string): CaptureIng let anyMaterialized = false; for (const parsed of parsedList) { - if (parsed.class === 'claim') { + if (parsed.class === 'claim' && !isFoldableAddClaim(p.body_text ?? '', parsed.symbol, parsed.shares)) { stats.claims++; continue; } - if (parsed.class !== 'capture') continue; + if (parsed.class !== 'capture' && parsed.class !== 'claim') continue; if (!parsed.symbol) continue; const asOf = normalizePostedAtDate(p.posted_at); @@ -108,17 +130,33 @@ export function ingestFundCaptures(db: DatabaseSync, fundId: string): CaptureIng ? parsed.instrument : null; const existing = selectExisting.get(fundId, parsed.symbol, evidenceUrl) as { id?: string } | undefined; + let shares = parsed.shares ?? null; + if (parsed.class === 'claim') { + // Fold the delta once. A re-run of the same tweet must not add it again. + if (existing?.id) { + // Keep the already-folded share count; only refresh non-qty fields. + db.prepare( + `UPDATE fund_position_records SET value_usd=COALESCE(?, value_usd), cost_basis=COALESCE(?, cost_basis), as_of=? WHERE id=?`, + ).run(parsed.value_usd ?? null, parsed.cost_basis ?? null, asOf, existing.id); + stats.refreshed++; + stats.captures++; + anyMaterialized = true; + continue; + } + const prior = latestInstrumentShares(db, fundId, parsed.symbol, instrumentNote); + shares = (prior ?? 0) + (parsed.shares ?? 0); + } if (existing?.id) { updateExisting.run( - parsed.shares ?? null, parsed.value_usd ?? null, parsed.cost_basis ?? null, asOf, + shares, parsed.value_usd ?? null, parsed.cost_basis ?? null, asOf, instrumentNote, existing.id, ); stats.refreshed++; } else { insertNew.run( crypto.randomUUID(), fundId, parsed.symbol, - parsed.shares ?? null, parsed.value_usd ?? null, parsed.cost_basis ?? null, asOf, evidenceUrl, + shares, parsed.value_usd ?? null, parsed.cost_basis ?? null, asOf, evidenceUrl, instrumentNote, new Date().toISOString(), ); stats.inserted++; @@ -136,6 +174,81 @@ export function ingestFundCaptures(db: DatabaseSync, fundId: string): CaptureIng return stats; } +function hasQuotedPostIdColumn(db: DatabaseSync): boolean { + const cols = db.prepare('PRAGMA table_info(x_cookie_posts)').all() as Array<{ name: string }>; + return cols.some((c) => c.name === 'quoted_post_id'); +} + +/** Quote-tweet with no ticker: inherit the symbol from the quoted capture. */ +export function inheritSymbolFromQuotedPost( + db: DatabaseSync, + handle: string, + quotedPostId: string | null | undefined, +): string | undefined { + if (!quotedPostId) return undefined; + const quoted = db.prepare( + `SELECT body_text FROM x_cookie_posts WHERE post_id = ?`, + ).get(quotedPostId) as { body_text: string | null } | undefined; + if (quoted?.body_text) { + const fromQuote = extractCaptures(quoted.body_text).find((p) => p.symbol)?.symbol; + if (fromQuote) return fromQuote; + } + const evidence = `https://x.com/${handle}/status/${quotedPostId}`; + const row = db.prepare( + `SELECT symbol FROM fund_position_records WHERE evidence_url = ? ORDER BY as_of DESC LIMIT 1`, + ).get(evidence) as { symbol: string } | undefined; + return row?.symbol; +} + +function capturesForPost(db: DatabaseSync, handle: string, p: PostRow) { + const parsedList = extractCaptures(p.body_text ?? ''); + const inherited = inheritSymbolFromQuotedPost(db, handle, p.quoted_post_id); + if (!inherited) return parsedList; + for (const parsed of parsedList) { + if (!parsed.symbol) parsed.symbol = inherited; + } + if (parsedList.length === 0) { + const one = classifyPost(p.body_text ?? ''); + if (one.class !== 'none') { + one.symbol = inherited; + parsedList.push(one); + } + } + return parsedList; +} + +/** Clean single add ("I added 115,000 shares in ENHA at $1.79"). Not an order-fill story. */ +export function isFoldableAddClaim( + text: string, + symbol: string | undefined, + shares: number | undefined, +): boolean { + if (!symbol || shares == null || shares <= 0) return false; + const qtyMentions = (text.match(/([\d.,]+\s*[MBK]?\s*shares)/gi) ?? []).length; + if (qtyMentions !== 1) return false; + if (/\bfilled\b|\bshare orders?\b/i.test(text)) return false; + if (/\bsold\b|\btrimmed?\b/i.test(text)) return false; + return /\badded\b|\bbought\b|\bpurchased\b/i.test(text); +} + +function latestInstrumentShares( + db: DatabaseSync, + fundId: string, + symbol: string, + instrumentNote: string | null, +): number | null { + const inst = instrumentNote ?? ''; + const row = db.prepare( + `SELECT shares FROM fund_position_records + WHERE fund_id = ? AND symbol = ? + AND CASE WHEN notes IN ('call','put') THEN notes ELSE '' END = ? + ORDER BY as_of DESC, created_at DESC + LIMIT 1`, + ).get(fundId, symbol, inst) as { shares: number | null } | undefined; + if (!row || row.shares == null) return null; + return row.shares; +} + /** posted_at may be ISO or Twitter "Wed Jul 15 20:43:28 +0000 2026". */ function normalizePostedAtDate(postedAt: string): string { if (/^\d{4}-\d{2}-\d{2}/.test(postedAt)) return postedAt.slice(0, 10); diff --git a/app/server/src/services/cusipRegistry.ts b/app/server/src/services/cusipRegistry.ts index 75cb3c7..fc22cea 100644 --- a/app/server/src/services/cusipRegistry.ts +++ b/app/server/src/services/cusipRegistry.ts @@ -17,8 +17,13 @@ export const CUSIP_TO_SYMBOL: Record = { Q4982L109: 'IREN', // IREN Limited '46438F101': 'IBIT', // iShares Bitcoin Trust ETF '46438R105': 'ETHA', // iShares Ethereum Trust ETF + '526057104': 'LEN', // Lennar Corporation + '583543301': 'SLNH', // Soluna Holdings, Inc. (ISIN US5835433013) + '64428N109': 'NUAI', // New Era Energy & Digital, Inc. '670100205': 'NVO', // Novo-Nordisk A/S '683712103': 'OPEN', // Opendoor Technologies Inc. + '84841L506': 'ANY', // Sphere 3D Corp. (post Feb 2026 reverse split) + '88636Y888': 'IRE', // Defiance Daily Target 2X Long IREN ETF '713448108': 'PEP', // PepsiCo, Inc. '75886F107': 'REGN', // Regeneron Pharmaceuticals, Inc. '862945300': 'ASST', // Strive, Inc. diff --git a/app/server/src/services/reverse13fRefresh.ts b/app/server/src/services/reverse13fRefresh.ts index 27694cf..de2512a 100644 --- a/app/server/src/services/reverse13fRefresh.ts +++ b/app/server/src/services/reverse13fRefresh.ts @@ -374,7 +374,7 @@ export async function refreshHoldersViaReverse13f( INSERT INTO institution_filings (filer_cik, filer_name, filer_sic, symbol, form, shares, value_usd, reported_quarter, filed_at, accession, fetched_at, put_call) VALUES (?, ?, NULL, ?, '13F-HR', ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(filer_cik, symbol, reported_quarter, form) DO UPDATE SET + ON CONFLICT(filer_cik, symbol, reported_quarter, form, put_call) DO UPDATE SET shares = excluded.shares, value_usd = excluded.value_usd, filer_name = COALESCE(excluded.filer_name, institution_filings.filer_name), diff --git a/app/server/src/services/secDataFetcher.ts b/app/server/src/services/secDataFetcher.ts index 7a4aa4b..f7a44b2 100644 --- a/app/server/src/services/secDataFetcher.ts +++ b/app/server/src/services/secDataFetcher.ts @@ -801,7 +801,7 @@ async function storeMatchedHoldings( INSERT INTO institution_filings (filer_cik, filer_name, filer_sic, symbol, form, shares, value_usd, reported_quarter, filed_at, accession, fetched_at, put_call) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(filer_cik, symbol, reported_quarter, form) DO UPDATE SET + ON CONFLICT(filer_cik, symbol, reported_quarter, form, put_call) DO UPDATE SET shares = excluded.shares, value_usd = excluded.value_usd, filer_name = COALESCE(excluded.filer_name, institution_filings.filer_name), @@ -895,7 +895,9 @@ async function fetchAndStoreInstitutionalHolders( return tb - ta; }); // Cold symbols need a bigger first pass; warm symbols only chase new filings. - const maxDownloads = knownAccessions.size >= 50 ? 40 : 100; + // Filing-deadline days dump hundreds of new 13F-HR accessions; 40/run + // cannot keep Q2 coverage current when the job actually finishes. + const maxDownloads = knownAccessions.size >= 50 ? 80 : 120; let downloaded = 0; let skippedKnown = 0; for (const filing of ordered) { diff --git a/app/server/src/trpc/router.ts b/app/server/src/trpc/router.ts index 7b3a234..bc47932 100644 --- a/app/server/src/trpc/router.ts +++ b/app/server/src/trpc/router.ts @@ -39,7 +39,16 @@ import { ConfluenceRepository, rackFromSlots } from '../db/confluenceRepository. import { runSlotBacktest, signalHistoryToStats, type ConfluenceFireEvent } from '../confluence/confluenceBacktest.ts'; import { detectPictureChange } from '../confluence/confluenceRack.ts'; import { runCorridorBacktest, aggregateBacktest } from '../analysis/corridorBacktest.ts'; -import { runConfluenceEvaluationCycle } from '../confluence/confluenceEngine.ts'; +import { + runConfluenceEvaluationCycle, + runConfluenceReplay, + replayCoverage, + loadActivePredicates, + buildRecentZones, + scoreSymbolUnderPrior, + learningLedgerStatus, +} from '../confluence/confluenceEngine.ts'; +import { REPLAY_LOOKBACK_DAYS } from '../confluence/confluenceSeed.ts'; import { CorridorRepository } from '../db/corridorRepository.ts'; // --------------------------------------------------------------------------- @@ -376,12 +385,14 @@ const marketRouter = router({ const byKey = new Map(entries.map((e) => [e.key, e])); const val = (key: string): T | null => (byKey.get(key)?.value ?? null) as T | null; const stale = (key: string): boolean => byKey.get(key)?.isStale ?? true; + const fetchedAt = (key: string): string | null => byKey.get(key)?.fetchedAt ?? null; return { symbol, quote: val(k.quote), candles: val(k.candles), sector: val(k.sector), stale: { quote: stale(k.quote), candles: stale(k.candles), sector: stale(k.sector) }, + observed: { quote: fetchedAt(k.quote), candles: fetchedAt(k.candles), sector: fetchedAt(k.sector) }, }; }), @@ -411,6 +422,7 @@ const marketRouter = router({ candles: PriceCandle[] | null; sector: SymbolMeta | null; stale: { quote: boolean; candles: boolean; sector: boolean }; + observed: { quote: string | null; candles: string | null; sector: string | null }; }> = []; for (const symbol of symbols) { @@ -428,6 +440,11 @@ const marketRouter = router({ candles: byKey.get(kCandles)?.isStale ?? true, sector: byKey.get(kSector)?.isStale ?? true, }, + observed: { + quote: byKey.get(kQuote)?.fetchedAt ?? null, + candles: byKey.get(kCandles)?.fetchedAt ?? null, + sector: byKey.get(kSector)?.fetchedAt ?? null, + }, }); } @@ -617,17 +634,23 @@ const marketRouter = router({ }), candles: publicProcedure - .input(z.object({ symbol: z.string().min(1), timeframe: z.enum(['1d', '1wk', '1mo']).default('1d') })) + .input(z.object({ symbol: z.string().min(1), timeframe: z.enum(['1m', '5m', '1d', '1wk', '1mo']).default('1d') })) .query(async ({ ctx, input }) => { const symbol = input.symbol.toUpperCase(); const key = `yfinance:candles:${symbol}:${input.timeframe}`; const entry = await ctx.cache.get(key); - return { symbol, timeframe: input.timeframe, candles: (entry.value ?? []), isStale: entry.isStale }; + return { + symbol, + timeframe: input.timeframe, + candles: (entry.value ?? []), + isStale: entry.isStale, + fetchedAt: entry.provenance?.fetchedAt ?? null, + }; }), indicators: publicProcedure .input(z.object({ symbol: z.string().min(1), - timeframe: z.enum(['1d', '1wk', '1mo']).default('1d'), + timeframe: z.enum(['1m', '5m', '1d', '1wk', '1mo']).default('1d'), periods: z.object({ ema: z.array(z.number().int()).default([9, 21, 50, 200]), rsi: z.number().int().default(14), @@ -1859,8 +1882,41 @@ const adminRouter = router({ }), xAccountsList: adminProcedure.query(({ ctx }) => { - const rows = ctx.db.prepare("SELECT id, symbol, handle, COALESCE(label, '') AS label, created_at FROM x_accounts ORDER BY symbol, handle").all(); - return (rows ?? []) as Array<{id: string; symbol: string; handle: string; label: string; created_at: string}>; + const rows = ctx.db.prepare("SELECT id, symbol, handle, COALESCE(label, '') AS label, created_at FROM x_accounts ORDER BY symbol, handle").all() as Array<{id: string; symbol: string; handle: string; label: string; created_at: string}>; + const handles = [...new Set(rows.map((r) => r.handle))]; + const pullByHandle = new Map(); + const lastPostByHandle = new Map(); + if (handles.length > 0) { + const keys = handles.flatMap((h) => [`x:timeline:${h}`, `x:timeline:${h.toLowerCase()}`]); + const jobs = ctx.db.prepare( + `SELECT key, status, last_attempt, error FROM adapter_queue WHERE key IN (${keys.map(() => '?').join(',')})`, + ).all(...keys) as Array<{ key: string; status: string; last_attempt: string | null; error: string | null }>; + for (const j of jobs) { + pullByHandle.set(j.key.slice('x:timeline:'.length), { + status: j.status, + lastAttempt: j.last_attempt, + error: j.error, + }); + } + const posts = ctx.db.prepare( + `SELECT lower(author_handle) AS h, MAX(posted_at) AS last_post + FROM x_cookie_posts WHERE lower(author_handle) IN (${handles.map(() => '?').join(',')}) + GROUP BY 1`, + ).all(...handles.map((h) => h.toLowerCase())) as Array<{ h: string; last_post: string | null }>; + for (const p of posts) { + if (p.last_post) lastPostByHandle.set(p.h, p.last_post); + } + } + return rows.map((r) => { + const pull = pullByHandle.get(r.handle) ?? pullByHandle.get(r.handle.toLowerCase()) ?? null; + return { + ...r, + pullStatus: pull?.status ?? null, + pullAt: pull?.lastAttempt ?? null, + pullError: pull?.error ?? null, + lastPostAt: lastPostByHandle.get(r.handle.toLowerCase()) ?? null, + }; + }); }), xAccountAdd: adminProcedure @@ -4604,6 +4660,8 @@ const xRouter = router({ nextCursor: null, configured: true as const, health: null, + lastPullAt: null, + queuedHandles: 0, }; } @@ -4653,6 +4711,17 @@ const xRouter = router({ const healthRow = ctx.db.prepare('SELECT healthy, last_error, updated_at FROM x_credentials WHERE id=?').get('singleton') as { healthy?: number; last_error?: string | null } | undefined; + const pullKeys = handles.flatMap((h) => [`x:timeline:${h}`, `x:timeline:${h.toLowerCase()}`]); + const pullJobs = pullKeys.length + ? ctx.db.prepare( + `SELECT key, status, last_attempt, error FROM adapter_queue WHERE key IN (${pullKeys.map(() => '?').join(',')})`, + ).all(...pullKeys) as Array<{ key: string; status: string; last_attempt: string | null; error: string | null }> + : []; + const lastPullAt = pullJobs.map((j) => j.last_attempt).filter((t): t is string => Boolean(t)).sort().at(-1) ?? null; + const queuedHandles = new Set( + pullJobs.filter((j) => j.status === 'pending' && !j.last_attempt).map((j) => j.key.slice('x:timeline:'.length).toLowerCase()), + ).size; + return { cashtagPosts: page, accountPosts: [], @@ -4662,6 +4731,8 @@ const xRouter = router({ healthy: healthRow.healthy === 1 ? ('healthy' as const) : ('degraded' as const), lastError: healthRow.last_error ?? null, } : null, + lastPullAt, + queuedHandles, }; }), @@ -5238,17 +5309,22 @@ const symbolsRouter = router({ // Tier 1 — tracked funds holding the symbol (most recent record per fund), with weight in their disclosed book. const trackedRows = db.prepare( - `SELECT tf.id AS fund_id, tf.fund_name, fpr.symbol, fpr.shares, fpr.value_usd, fpr.as_of, fpr.source + `SELECT tf.id AS fund_id, tf.fund_name, fpr.symbol, fpr.shares, fpr.value_usd, + fpr.as_of, fpr.source, fpr.notes FROM fund_position_records fpr JOIN tracked_funds tf ON tf.id = fpr.fund_id AND tf.enabled = 1 JOIN ( - SELECT fund_id, MAX(as_of) AS max_as_of + SELECT fund_id, + CASE WHEN notes IN ('call','put') THEN notes ELSE '' END AS inst, + MAX(as_of) AS max_as_of FROM fund_position_records WHERE symbol = ? - GROUP BY fund_id + GROUP BY fund_id, CASE WHEN notes IN ('call','put') THEN notes ELSE '' END ) latest ON latest.fund_id = fpr.fund_id AND latest.max_as_of = fpr.as_of + AND CASE WHEN fpr.notes IN ('call','put') THEN fpr.notes ELSE '' END = latest.inst WHERE fpr.symbol = ? - ORDER BY tf.fund_name ASC`, + AND (fpr.shares IS NULL OR fpr.shares > 0) + ORDER BY tf.fund_name ASC, fpr.notes ASC`, ).all(symbol, symbol) as Array>; // Weight = position value / total disclosed book value at the fund's live book (per fund). @@ -5268,6 +5344,7 @@ const symbolsRouter = router({ valueUsd: r.value_usd ?? null, asOf: r.as_of, source: r.source, + notes: (r.notes as string | null) ?? null, weightPct: bookValueByFund.get(r.fund_id) ? ((r.value_usd ?? 0) / bookValueByFund.get(r.fund_id)!) * 100 : null, @@ -5290,16 +5367,20 @@ const fundsRouter = router({ /** List operator-curated tracked funds (v1: Alpine Fox). */ list: publicProcedure .query(async ({ ctx }) => { - const { listTrackedFunds } = await import('../db/fundRepository.ts'); - return listTrackedFunds(ctx.db, { includeDisabled: true }); + const { listTrackedFunds, fundsFreshness } = await import('../db/fundRepository.ts'); + const funds = listTrackedFunds(ctx.db, { includeDisabled: true }); + const fresh = fundsFreshness(ctx.db, funds); + return funds.map((f) => ({ ...f, freshness: fresh.get(f.id) ?? null })); }), /** Get a single tracked fund by id. */ get: publicProcedure .input(z.object({ id: z.string().min(1) })) .query(async ({ ctx, input }) => { - const { getTrackedFund } = await import('../db/fundRepository.ts'); - return getTrackedFund(ctx.db, input.id); + const { getTrackedFund, fundFreshness } = await import('../db/fundRepository.ts'); + const fund = getTrackedFund(ctx.db, input.id); + if (!fund) return null; + return { ...fund, freshness: fundFreshness(ctx.db, fund) }; }), /** Live Book for a fund — most recent record per symbol, source-labeled. */ @@ -5307,7 +5388,20 @@ const fundsRouter = router({ .input(z.object({ fundId: z.string().min(1) })) .query(async ({ ctx, input }) => { const { liveBook } = await import('../db/fundRepository.ts'); - return liveBook(ctx.db, input.fundId); + const book = liveBook(ctx.db, input.fundId); + if (ctx.xAdapter) { + const { annotateCaptureEvidence } = await import('../services/captureEvidence.ts'); + // Cache only on the request path. Live bird reads here were serial and + // routinely blew the 8s client timeout, so the fund page rendered empty. + await annotateCaptureEvidence( + ctx.db, + book, + (id) => ctx.xAdapter!.readTweetStatus(id), + Date.now(), + { live: false }, + ); + } + return book; }), /** Full append-only position timeline for a fund. */ @@ -5330,7 +5424,11 @@ const fundsRouter = router({ })) .mutation(async ({ ctx, input }) => { const { upsertTrackedFund } = await import('../db/fundRepository.ts'); - return upsertTrackedFund(ctx.db, input); + const fund = upsertTrackedFund(ctx.db, input); + if (fund.enabled && fund.x_handle) { + try { await ctx.queue.queue(`x:timeline:${fund.x_handle}`); } catch { /* hourly schedule is the fallback */ } + } + return fund; }), adminSetEnabled: adminProcedure @@ -5530,11 +5628,90 @@ const confluenceRouter = router({ body: (meta?.body ?? 'bull') as 'bull' | 'bear' | 'exit', firedAt: f.firedAt, qualityAtFire: f.qualityAtFire, + verdict: f.verdict, + priceConfirmed: f.priceConfirmed, }; }); return { fires }; }), + /** Last few entry/exit windows for the open symbol, plus the active derived rules. */ + recentZones: publicProcedure + .input(z.object({ + symbol: z.string().min(1).max(12), + rackId: z.string().min(1).optional(), + entries: z.number().int().min(1).max(8).optional(), + exits: z.number().int().min(1).max(8).optional(), + })) + .query(async ({ ctx, input }) => { + const repo = new ConfluenceRepository(ctx.db); + const symbol = input.symbol.toUpperCase(); + const rackId = input.rackId ?? (repo.listSystemRacks()[0]?.id ?? null); + if (!rackId) { + return { + symbol, + rackId: null, + entries: [], + exits: [], + entryRule: { label: 'n/a', source: 'baseline', derivedAt: null, sampleCaveat: 'No rack seeded.', train: null, validate: null }, + exitRule: { label: 'n/a', source: 'baseline', derivedAt: null, sampleCaveat: 'No rack seeded.', train: null, validate: null }, + symbolScore: { + entry: { zones: 0, resolved: 0, confirmed: 0, hitRate: null }, + exit: { zones: 0, resolved: 0, confirmed: 0, hitRate: null }, + }, + learningLedger: { ready: false, filled: 0, total: 0 }, + coverage: { evaluatedDays: 0, lookbackDays: REPLAY_LOOKBACK_DAYS, replayComplete: false }, + }; + } + const entry = await ctx.cache.get(`yfinance:candles:${symbol}:1d`); + const candles = (entry?.value ?? []) as PriceCandle[]; + const zones = buildRecentZones(ctx.db, symbol, rackId, candles, input.entries ?? 3, input.exits ?? 3); + const preds = loadActivePredicates(ctx.db, rackId); + const coverage = replayCoverage(ctx.db, symbol, rackId, REPLAY_LOOKBACK_DAYS, candles.length); + if (!coverage.replayComplete) { + void runConfluenceReplay(ctx.db, ctx.cache, { + symbols: [symbol], + symbolsPerTick: 1, + budgetDaysPerSymbol: 80, + }).catch((e) => console.error('[confluence] on-read replay failed:', e)); + } + const entryScore = scoreSymbolUnderPrior(ctx.db, symbol, rackId, candles, 'entry', preds.entry, preds.exit); + const exitScore = scoreSymbolUnderPrior(ctx.db, symbol, rackId, candles, 'exit', preds.exit, preds.entry); + const ledger = await learningLedgerStatus(ctx.db, ctx.cache); + const ruleCard = ( + stored: typeof preds.entryRule, + pred: typeof preds.entry, + side: 'entry' | 'exit', + ) => stored ? { + label: pred.label, + source: stored.source, + derivedAt: stored.derivedAt, + sampleCaveat: stored.sampleCaveat, + train: JSON.parse(stored.trainStatsJson), + validate: JSON.parse(stored.validateStatsJson), + } : { + label: pred.label, + source: 'baseline', + derivedAt: null, + sampleCaveat: ledger.ready + ? 'Learning ledger is full. A sector-checked prior has not been stored yet.' + : 'Default window definition. Sector-checked prior is not ready (learning ledger still filling).', + train: null, + validate: null, + }; + return { + symbol, + rackId, + entries: zones.entries, + exits: zones.exits, + entryRule: ruleCard(preds.entryRule, preds.entry, 'entry'), + exitRule: ruleCard(preds.exitRule, preds.exit, 'exit'), + symbolScore: { entry: entryScore, exit: exitScore }, + learningLedger: { ready: ledger.ready, filled: ledger.symbols.filter((s) => s.ready).length, total: ledger.symbols.length }, + coverage, + }; + }), + /** Create or update a user-owned rack. */ saveRack: protectedProcedure .input(z.object({ @@ -5662,10 +5839,14 @@ const confluenceRouter = router({ runEvaluationNow: protectedProcedure .input(z.object({ symbol: z.string().min(1).max(12).optional() })) .mutation(async ({ ctx, input }) => { - const summary = await runConfluenceEvaluationCycle(ctx.db, ctx.cache, { - symbols: input?.symbol ? [input.symbol] : undefined, + const symbols = input?.symbol ? [input.symbol] : undefined; + const replay = await runConfluenceReplay(ctx.db, ctx.cache, { + symbols: symbols ?? undefined, + symbolsPerTick: symbols ? 1 : 3, + budgetDaysPerSymbol: symbols ? 200 : 40, }); - return summary; + const summary = await runConfluenceEvaluationCycle(ctx.db, ctx.cache, { symbols }); + return { ...summary, replay }; }), }); diff --git a/app/src/__tests__/dealerMapChrome.test.ts b/app/src/__tests__/dealerMapChrome.test.ts new file mode 100644 index 0000000..ca1a1bf --- /dev/null +++ b/app/src/__tests__/dealerMapChrome.test.ts @@ -0,0 +1,154 @@ +import { test } from "node:test"; +import { strict as assert } from "node:assert"; +import { + levelsLine, + normalizeMetric, + providerLabel, + scenarioLine, + trustStatus, +} from "../lib/dealerMapChrome.ts"; + +test("levelsLine lists king flip walls then regime", () => { + const parts = levelsLine({ + regimeAtSpot: "positive", + levels: { + king: { strike: 41, expiry: "2026-08-21", gex: 1 }, + gatekeepers: [], + flip: 38, + callWall: 50, + putWall: 30, + airPockets: [], + clusters: [], + }, + }); + assert.deepEqual( + parts.map((p) => `${p.label} ${p.value}`.trim()), + ["King 41", "Flip 38", "Call 50", "Put 30", "+GEX near spot"], + ); +}); + +test("trustStatus hides a complete book with ok greeks", () => { + assert.equal( + trustStatus({ + status: "ok", + integrity: { + status: "complete", + checks: [], + hardFailed: [], + softFailed: [], + metrics: { + expiries: 5, + oiEligible: 10, + included: 10, + cells: 40, + byStrike: 20, + nearSpotOiStrikes: 8, + minOiStrikesPerExpiry: 4, + includePct: 100, + badIvSkipPct: 0, + bsmFillPct: 0, + }, + }, + quality: { + greekQuality: "ok", + oiEligible: 10, + included: 10, + skippedBadIv: 0, + bsmFills: 0, + fallbackIvUses: 0, + bsmFillPct: 0, + badIvSkipPct: 0, + fallbackIvPct: 0, + notes: [], + }, + }), + null, + ); +}); + +test("trustStatus surfaces incomplete hard fails", () => { + const s = trustStatus({ + status: "incomplete", + integrity: { + status: "incomplete", + checks: [ + { id: "chain_age", level: "hard", ok: false, detail: "Chain is 3 sessions old." }, + ], + hardFailed: ["chain_age"], + softFailed: [], + metrics: { + expiries: 0, + oiEligible: 0, + included: 0, + cells: 0, + byStrike: 0, + nearSpotOiStrikes: 0, + minOiStrikesPerExpiry: 0, + includePct: 0, + badIvSkipPct: 0, + bsmFillPct: 0, + }, + }, + }); + assert.equal(s?.tone, "incomplete"); + assert.equal(s?.label, "Incomplete"); + assert.ok(s?.details[0]?.includes("3 sessions")); +}); + +test("trustStatus surfaces degraded greeks without a complete chip", () => { + const s = trustStatus({ + status: "ok", + integrity: { + status: "complete", + checks: [], + hardFailed: [], + softFailed: [], + metrics: { + expiries: 5, + oiEligible: 10, + included: 10, + cells: 40, + byStrike: 20, + nearSpotOiStrikes: 8, + minOiStrikesPerExpiry: 4, + includePct: 100, + badIvSkipPct: 0, + bsmFillPct: 100, + }, + }, + quality: { + greekQuality: "degraded", + oiEligible: 10, + included: 10, + skippedBadIv: 0, + bsmFills: 10, + fallbackIvUses: 0, + bsmFillPct: 100, + badIvSkipPct: 0, + fallbackIvPct: 0, + notes: ["Gamma is local BSM when the vendor omits it."], + }, + }); + assert.equal(s?.tone, "degraded"); + assert.equal(s?.label, "Degraded"); + assert.ok(s?.details.some((d) => /BSM/i.test(d))); +}); + +test("providerLabel maps yfinance to Yahoo snapshot", () => { + assert.equal(providerLabel("yfinance"), "Yahoo snapshot"); + assert.equal(providerLabel(null), "Snapshot"); +}); + +test("normalizeMetric keeps only GEX vs VEX", () => { + assert.equal(normalizeMetric("gex"), "gex"); + assert.equal(normalizeMetric("vex"), "vex"); + assert.equal(normalizeMetric("oi"), "gex"); + assert.equal(normalizeMetric(null), "gex"); +}); + +test("scenarioLine joins pattern names without chip soup", () => { + assert.equal( + scenarioLine("pos_gex_pos_vex", [{ id: "whipsaw", confidence: 0.6 }]), + "Calm · Whipsaw", + ); +}); diff --git a/app/src/__tests__/ema921Range.test.ts b/app/src/__tests__/ema921Range.test.ts new file mode 100644 index 0000000..e801c60 --- /dev/null +++ b/app/src/__tests__/ema921Range.test.ts @@ -0,0 +1,87 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { computeEma921Range } from "../lib/ema921Range.ts"; + +function bar(c: number): { o: number; h: number; l: number; c: number } { + return { o: c, h: c + 0.4, l: c - 0.4, c }; +} + +/** 21 bars at 100 seed both EMAs, then a step up so 9 crosses above 21. */ +function stepUp(): Array<{ o: number; h: number; l: number; c: number }> { + return [ + ...Array.from({ length: 21 }, () => bar(100)), + ...Array.from({ length: 15 }, () => bar(120)), + ]; +} + +/** After an up-cross, a step down so 21 crosses back over 9. */ +function stepUpThenDown(): Array<{ o: number; h: number; l: number; c: number }> { + return [ + ...Array.from({ length: 21 }, () => bar(100)), + ...Array.from({ length: 20 }, () => bar(130)), + ...Array.from({ length: 20 }, () => bar(70)), + ]; +} + +describe("computeEma921Range", () => { + it("posts no levels when there is no 9/21 cross", () => { + const candles = Array.from({ length: 30 }, (_, i) => { + const c = 50 + i * 0.01; + return { o: c, h: c, l: c, c }; + }); + const r = computeEma921Range(candles, 30); + assert.equal(r.bars.some((b) => b.cross != null), false); + assert.equal(r.levels.up1, null); + assert.equal(r.levels.down1, null); + }); + + it("posts two up levels above the cross-up open", () => { + const candles = stepUp(); + const r = computeEma921Range(candles, 500); + const upAt = r.bars.findIndex((b) => b.cross === "up"); + assert.ok(upAt > 0, "expected a 9-over-21"); + assert.ok(r.levels.up1 != null && r.levels.up2 != null); + assert.equal(r.levels.upFromIndex, upAt); + const open = candles[upAt]!.o; + assert.ok(r.levels.up1! >= open); + assert.ok(r.levels.up2! >= r.levels.up1!); + }); + + it("a later 9-over-21 replaces the up pair", () => { + const candles = [ + ...Array.from({ length: 21 }, () => bar(100)), + ...Array.from({ length: 15 }, () => bar(130)), + ...Array.from({ length: 15 }, () => bar(80)), + ...Array.from({ length: 15 }, () => bar(140)), + ]; + const r = computeEma921Range(candles, 500); + const ups = r.bars.map((b, i) => (b.cross === "up" ? i : -1)).filter((i) => i >= 0); + assert.ok(ups.length >= 2, `expected 2+ up crosses, got ${ups.length}`); + assert.equal(r.levels.upFromIndex, ups[ups.length - 1]); + }); + + it("a 21-over-9 posts the down pair and keeps the last up pair", () => { + const candles = stepUpThenDown(); + const r = computeEma921Range(candles, 500); + const downAt = r.bars.findIndex((b) => b.cross === "down"); + assert.ok(downAt > 0, "expected a 21-over-9"); + assert.ok(r.levels.down1 != null && r.levels.down2 != null); + assert.equal(r.levels.downFromIndex, downAt); + assert.ok(r.levels.up1 != null, "up pair should remain"); + assert.ok(r.levels.down2! <= r.levels.down1!); + }); + + it("lookback ignores crosses older than the window", () => { + const candles = Array.from({ length: 80 }, (_, i) => { + const c = i < 20 ? 60 + i : i < 40 ? 80 - (i - 20) : 60 + (i - 40) * 0.2; + return { o: c, h: c + 0.3, l: c - 0.3, c }; + }); + const full = computeEma921Range(candles, 500); + const tight = computeEma921Range(candles, 15); + const lastDown = [...full.bars].map((b, i) => (b.cross === "down" ? i : -1)).filter((i) => i >= 0).pop(); + if (lastDown != null && lastDown < candles.length - 15) { + assert.equal(tight.levels.downFromIndex, null); + } + assert.equal(tight.bars.length, full.bars.length); + }); +}); diff --git a/app/src/__tests__/lastUpdated.test.ts b/app/src/__tests__/lastUpdated.test.ts new file mode 100644 index 0000000..2b42ba7 --- /dev/null +++ b/app/src/__tests__/lastUpdated.test.ts @@ -0,0 +1,25 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { lastUpdatedText, quoteStampTone } from "../lib/lastUpdatedCopy.ts"; + +describe("lastUpdatedText", () => { + it("distinguishes queued vs pulled vs failed", () => { + assert.equal(lastUpdatedText({ label: "X", status: "pending" }), "X queued, not pulled yet"); + assert.equal(lastUpdatedText({ label: "X", status: "in_flight" }), "X pulling now"); + assert.equal(lastUpdatedText({ label: "X", status: "none" }), "X never pulled"); + assert.match( + lastUpdatedText({ label: "X", status: "backoff", at: new Date(Date.now() - 60_000).toISOString(), error: "timeout" }), + /X last try failed 1m ago \(timeout\)/, + ); + }); +}); + +describe("quoteStampTone", () => { + const now = Date.parse("2026-08-17T16:54:00.000Z"); + + it("marks missing or old prints stale", () => { + assert.equal(quoteStampTone(null, now), "stale"); + assert.equal(quoteStampTone("2026-08-17T11:55:00.000Z", now), "stale"); + assert.equal(quoteStampTone("2026-08-17T16:53:30.000Z", now), "ok"); + }); +}); diff --git a/app/src/__tests__/priceChartGeometry.test.ts b/app/src/__tests__/priceChartGeometry.test.ts new file mode 100644 index 0000000..197f09e --- /dev/null +++ b/app/src/__tests__/priceChartGeometry.test.ts @@ -0,0 +1,105 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + emaRelation, + layoutCandle, + priceExtents, + ribbonPath, + ribbonRuns, +} from "../lib/priceChartGeometry.ts"; + +describe("layoutCandle", () => { + const yOf = (p: number) => 100 - p; + + it("maps an up bar to a hollow-ready body between open and close", () => { + const c = layoutCandle({ o: 10, h: 20, l: 8, c: 16 }, 50, yOf, 6); + assert.ok(c); + assert.equal(c.up, true); + assert.equal(c.cx, 50); + assert.equal(c.wickTop, 80); + assert.equal(c.wickBottom, 92); + assert.equal(c.bodyX, 47); + assert.equal(c.bodyY, 84); + assert.equal(c.bodyH, 6); + assert.equal(c.bodyW, 6); + }); + + it("keeps a doji body at least 1px tall", () => { + const c = layoutCandle({ o: 12, h: 14, l: 10, c: 12 }, 0, yOf, 4); + assert.ok(c); + assert.equal(c.bodyH, 1); + assert.equal(c.up, true); + }); + + it("returns null when a price is missing", () => { + assert.equal(layoutCandle({ o: 10, h: NaN, l: 8, c: 11 }, 0, yOf, 4), null); + }); +}); + +describe("priceExtents", () => { + it("pads high/low and folds in visible EMAs", () => { + const { minP, maxP } = priceExtents( + [{ h: 20, l: 10, ema9: 22, ema21: 9, ema200: 100 }], + { ema9: true, ema21: true, ema200: false }, + 0.1, + ); + assert.ok(maxP > 22); + assert.ok(minP < 9); + assert.ok(maxP < 100); + }); + + it("returns a fallback domain when empty", () => { + assert.deepEqual(priceExtents([]), { minP: 0, maxP: 1 }); + }); + + it("pads after folding annotation prices so a top range line is not flush", () => { + const { minP, maxP } = priceExtents( + [{ h: 20, l: 10 }], + { extras: [28, 6] }, + 0.1, + ); + assert.ok(maxP > 28); + assert.ok(minP < 6); + assert.ok(maxP - 28 >= (28 - 6) * 0.1 - 1e-9); + }); +}); + +describe("emaRelation", () => { + it("classifies 9 vs 21", () => { + assert.equal(emaRelation(11, 10), "above"); + assert.equal(emaRelation(9, 10), "below"); + assert.equal(emaRelation(10, 10), "even"); + assert.equal(emaRelation(undefined, 10), null); + }); +}); + +describe("ribbonRuns", () => { + it("splits at the interpolated 9/21 cross", () => { + const runs = ribbonRuns([ + { x: 0, ema9: 12, ema21: 10 }, + { x: 10, ema9: 8, ema21: 10 }, + ]); + assert.equal(runs.length, 2); + assert.equal(runs[0]!.above, true); + assert.equal(runs[1]!.above, false); + const cross = runs[0]!.samples[runs[0]!.samples.length - 1]!; + assert.ok(Math.abs(cross.x - 5) < 1e-9); + assert.ok(Math.abs(cross.ema9 - 10) < 1e-9); + assert.equal(cross.ema9, cross.ema21); + }); + + it("needs two valid samples", () => { + assert.equal(ribbonRuns([{ x: 0, ema9: 1, ema21: 2 }]).length, 0); + }); + + it("builds a closed path", () => { + const d = ribbonPath( + [ + { x: 0, ema9: 4, ema21: 2 }, + { x: 10, ema9: 6, ema21: 3 }, + ], + (p) => p, + ); + assert.equal(d, "M0,4L10,6L10,3L0,2Z"); + }); +}); diff --git a/app/src/__tests__/primary-rule-lint.test.ts b/app/src/__tests__/primary-rule-lint.test.ts index 02bb1b8..22f9f9e 100644 --- a/app/src/__tests__/primary-rule-lint.test.ts +++ b/app/src/__tests__/primary-rule-lint.test.ts @@ -44,8 +44,13 @@ test("panel/page/client source pass the Primary-Rule lint", () => { "../lib/trpc.ts", "../stores/active-symbol-store.ts", "../components/dealer-flow/DealerFlowView.tsx", + "../components/dealer-flow/DealerHeatmap.tsx", "../components/dealer-flow/DealerLevelsStrip.tsx", + "../components/dealer-flow/StudyDeskPanel.tsx", + "../lib/dealerMapChrome.ts", "../app/dealer-flow/page.tsx", + "../components/ConfluencePanel.tsx", + "../app/confluence/page.tsx", ]; for (const f of files) { const src = readFileSync(join(__dirname, f), "utf8"); diff --git a/app/src/__tests__/relativeTime.test.ts b/app/src/__tests__/relativeTime.test.ts new file mode 100644 index 0000000..df44b28 --- /dev/null +++ b/app/src/__tests__/relativeTime.test.ts @@ -0,0 +1,45 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { formatCountdown, formatRelativeTime, remainingMsUntil } from "../lib/relativeTime.ts"; + +describe("formatCountdown", () => { + it("formats remaining windows without rounding away seconds", () => { + assert.equal(formatCountdown(0), "0s"); + assert.equal(formatCountdown(-12), "0s"); + assert.equal(formatCountdown(1), "1s"); + assert.equal(formatCountdown(32_000), "32s"); + assert.equal(formatCountdown(4 * 60_000 + 32_000), "4m 32s"); + assert.equal(formatCountdown(60 * 60_000), "1h"); + assert.equal(formatCountdown(72 * 60_000 + 5_000), "1h 12m"); + }); +}); + +describe("remainingMsUntil", () => { + const now = Date.parse("2026-08-17T15:30:00.000Z"); + + it("returns zero for missing, invalid, or past stamps", () => { + assert.equal(remainingMsUntil(null, now), 0); + assert.equal(remainingMsUntil("not-a-date", now), 0); + assert.equal(remainingMsUntil("2026-08-17T15:29:00.000Z", now), 0); + }); + + it("returns remaining milliseconds for a future stamp", () => { + assert.equal(remainingMsUntil("2026-08-17T15:34:32.000Z", now), 4 * 60_000 + 32_000); + }); +}); + +describe("formatRelativeTime", () => { + const now = Date.parse("2026-08-16T15:30:00.000Z"); + + it("labels missing and invalid stamps", () => { + assert.equal(formatRelativeTime(null, now), "never"); + assert.equal(formatRelativeTime("not-a-date", now), "unknown"); + }); + + it("uses compact units", () => { + assert.equal(formatRelativeTime("2026-08-16T15:29:40.000Z", now), "just now"); + assert.equal(formatRelativeTime("2026-08-16T15:18:00.000Z", now), "12m ago"); + assert.equal(formatRelativeTime("2026-08-16T13:30:00.000Z", now), "2h ago"); + assert.equal(formatRelativeTime("2026-08-15T15:30:00.000Z", now), "1d ago"); + }); +}); diff --git a/app/src/app/admin/queue/page.tsx b/app/src/app/admin/queue/page.tsx index 61053e6..ad911b0 100644 --- a/app/src/app/admin/queue/page.tsx +++ b/app/src/app/admin/queue/page.tsx @@ -1,8 +1,10 @@ "use client"; import { useEffect, useState, useCallback } from "react"; import { AdminLayout } from "@/components/AdminLayout"; -import { api } from "@/lib/trpc"; +import { api, type SourceCooldown } from "@/lib/trpc"; import { InfoTooltip } from "@/components/InfoTooltip"; +import { formatCountdown, remainingMsUntil } from "@/lib/relativeTime"; +import { useVisibilityAwarePoll } from "@/lib/useVisibilityAwarePoll"; interface QueueItem { source: string; @@ -24,8 +26,13 @@ interface QueueStatus { last_error: string | undefined; counts: Record; lastErrors: Array<{ key: string; error: string | null }>; + sourceCooldowns?: SourceCooldown[]; pausedSources?: string[]; stoppedSources?: string[]; + pendingByKind?: Record; + demandSize?: number; + dataPlaneHealthy?: boolean; + dataPlaneNotes?: string[]; } interface ErrorLogEntry { @@ -93,6 +100,12 @@ function msToHuman(ms: number): string { return `${Math.round(ms / 86400000)}d`; } +function cooldownRemainingMs(cool: SourceCooldown | undefined, now: number): number { + if (!cool) return 0; + if (cool.until) return remainingMsUntil(cool.until, now); + return Math.max(0, cool.remainingMs); +} + export default function QueuePage() { const [queue, setQueue] = useState([]); const [status, setStatus] = useState(null); @@ -126,6 +139,7 @@ export default function QueuePage() { mode: string; } | null>(null); + const [clearingCooldown, setClearingCooldown] = useState(null); const [resettingSource, setResettingSource] = useState(null); const [resetResult, setResetResult] = useState>({}); const [controllingSource, setControllingSource] = useState<{ source: string; action: string } | null>(null); @@ -154,6 +168,7 @@ export default function QueuePage() { const [pausing, setPausing] = useState(false); const [restarting, setRestarting] = useState(false); + const [nowMs, setNowMs] = useState(() => Date.now()); const loadData = useCallback(async () => { try { @@ -183,6 +198,8 @@ export default function QueuePage() { return () => { clearTimeout(boot); clearInterval(interval); }; }, [loadData]); + useVisibilityAwarePoll(() => setNowMs(Date.now()), 1000); + const handlePause = async () => { setPausing(true); try { @@ -302,6 +319,20 @@ export default function QueuePage() { } }; + const handleClearCooldown = async (sourceKind: string) => { + setClearingCooldown(sourceKind); + setError(null); + try { + await api.admin.clearSourceCooldown(sourceKind); + setSuccess(`${sourceKind} cooldown cleared`); + await loadData(); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to clear cooldown"); + } finally { + setClearingCooldown(null); + } + }; + const handleResetBackoff = async (sourceKind: string) => { setResettingSource(sourceKind); try { @@ -425,7 +456,23 @@ export default function QueuePage() { } } + const cooldownBySource: Record = {}; + for (const cool of status?.sourceCooldowns ?? []) { + if (!cool.active && cooldownRemainingMs(cool, nowMs) <= 0) continue; + cooldownBySource[cool.source] = cool; + if (!sources[cool.source]) { + sources[cool.source] = { pending: 0, inFlight: 0, done: 0, backoff: 0, failed: 0, lastAttempt: null, hasBackoff: false }; + } + } + const sortedSources = Object.entries(sources).sort(([a], [b]) => a.localeCompare(b)); + const activeCooldowns = sortedSources + .map(([source]) => { + const cool = cooldownBySource[source]; + const remaining = cooldownRemainingMs(cool, nowMs); + return cool && remaining > 0 ? { source, cool, remaining } : null; + }) + .filter((row): row is { source: string; cool: SourceCooldown; remaining: number } => row != null); const sourceControl: Record = {}; for (const s of status?.stoppedSources ?? []) sourceControl[s] = "stopped"; @@ -511,7 +558,7 @@ export default function QueuePage() {

No queue data available

) : ( <> -
+
Pending Jobs waiting to be picked up by the next drain cycle. @@ -528,20 +575,43 @@ export default function QueuePage() { Backoff A vendor rate-limit or cool-down. Jobs stall and retry automatically once the window passes. + + Cooldown + Source-wide pause after a 429. Remaining time until this source accepts outbound calls again. + Failed Permanent failures (quarantine / max retries exceeded). See the Failed Jobs log below for details and manual retry.
- {sortedSources.some(([, v]) => v.backoff > 0) && ( -
- - Backoff active — jobs stalled and awaiting retry + {status && status.dataPlaneHealthy === false && (status.dataPlaneNotes?.length ?? 0) > 0 && ( +
+ + + Data plane: {status.dataPlaneNotes!.join(" · ")} +
)} - + {activeCooldowns.length > 0 && ( +
+ + + Cooldown active: {activeCooldowns.map((c) => `${c.source} ${formatCountdown(c.remaining)}`).join(", ")} + +
+ )} + + {sortedSources.some(([, v]) => v.backoff > 0) && ( +
+ + Backoff active - jobs stalled and awaiting retry +
+ )} + +
+
+ {sortedSources.map(([source, agg]) => { const ctrl = sourceControl[source] ?? "running"; + const cool = cooldownBySource[source]; + const coolMs = cooldownRemainingMs(cool, nowMs); + const cooling = coolMs > 0; return ( - + +
@@ -554,6 +624,7 @@ export default function QueuePage() { In-flightJobs actively being fetched right now. DoneCompleted and cached successfully. BackoffRate-limited; auto-retries after cool-down.CooldownLive remaining time on the source-wide 429 pause. Hits show how far up the 2/5/15/30/60m ladder this source is. FailedPermanent failures requiring manual retry. Last AttemptMost recent time any job for this source was attempted. @@ -567,8 +638,11 @@ export default function QueuePage() {
{source} @@ -584,6 +658,28 @@ export default function QueuePage() { 0 ? "text-up" : "text-fg-faint"}`}>{agg.inFlight} {agg.done} {agg.backoff} 0) ? "text-[#fbbf24]" : "text-fg-faint"}`}> + {cooling ? ( + 0 ? ` · ${cool.consecutiveHits} consecutive hit${cool.consecutiveHits === 1 ? "" : "s"}` : ""}` + : undefined + } + > + {formatCountdown(coolMs)} + {cool && cool.consecutiveHits > 1 ? ( + ×{cool.consecutiveHits} + ) : null} + + ) : source === "yfinance" && (status?.pendingByKind?.quote ?? 0) > 0 ? ( + + {status?.pendingByKind?.quote}q + + ) : ( + "-" + )} + 0 ? "text-danger" : "text-fg-faint"}`}>{agg.failed} {formatTimestamp(agg.lastAttempt)} @@ -630,8 +726,21 @@ export default function QueuePage() { )} - {(agg.hasBackoff || agg.failed > 0) && ( + {(cooling || agg.hasBackoff || agg.failed > 0) && (
+ {cooling && ( + + )} {agg.hasBackoff && (
+
{sortedSources.some(([, v]) => v.failed > 0) && (
diff --git a/app/src/app/admin/x-accounts/page.tsx b/app/src/app/admin/x-accounts/page.tsx index 1deaf49..5c08069 100644 --- a/app/src/app/admin/x-accounts/page.tsx +++ b/app/src/app/admin/x-accounts/page.tsx @@ -3,6 +3,8 @@ import { useEffect, useState } from "react"; import { AdminLayout } from "@/components/AdminLayout"; import { api } from "@/lib/trpc"; import type { XAccountRow, XCredentialsStatus } from "@/lib/trpc"; +import { LastUpdated } from "@/components/shared/LastUpdated"; +import type { PullStatus } from "@/components/shared/LastUpdated"; /** Operator tooling: manage X cookie credentials + tracked accounts per symbol. */ export default function XAccountsPage() { @@ -204,18 +206,30 @@ export default function XAccountsPage() { Symbol @ handle Label + Last pull + Last post {accounts.length === 0 && ( - No tracked accounts yet. Add one above. + No tracked accounts yet. Add one above. )} {accounts.map((a) => ( {a.symbol} @{a.handle} {a.label || "—"} + + + + + + diff --git a/app/src/app/alerts/page.tsx b/app/src/app/alerts/page.tsx index 9dc0953..232b6bf 100644 --- a/app/src/app/alerts/page.tsx +++ b/app/src/app/alerts/page.tsx @@ -1,16 +1,10 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { ExternalLink as ExternalLinkIcon } from "lucide-react"; import { LayoutShell } from "@/components/LayoutShell"; import { FeatureGate } from "@/components/FeatureGate"; +import { AlertEventCard } from "@/components/AlertEventCard"; import { api, type AlertEventRow, type AlertSubscriptionRow, type AlertTypeToggleRow } from "@/lib/trpc"; -const SEVERITY_COLORS: Record = { - info: "text-blue-400 bg-blue-950/30 border-blue-500/30", - warning: "text-amber-400 bg-amber-950/30 border-amber-500/30", - critical: "text-red-400 bg-red-950/30 border-red-500/30", -}; - function AlertsPageInner() { const [tab, setTab] = useState<"events" | "subscriptions">("events"); const [events, setEvents] = useState([]); @@ -22,6 +16,7 @@ function AlertsPageInner() { const [clearing, setClearing] = useState(false); const [confirmClear, setConfirmClear] = useState(false); const [totalCount, setTotalCount] = useState(null); + const [expandedId, setExpandedId] = useState(null); const confirmTimer = useRef | null>(null); const loadEvents = async () => { @@ -199,39 +194,13 @@ function AlertsPageInner() { ) : (
{events.map((e) => ( -
-
-
-
- {e.severity} - {e.title} - {e.symbol && {e.symbol}} -
-

{e.description}

- {typeof e.payload?.url === "string" && ( - - View filing on SEC.gov - - - )} -

{new Date(e.createdAt).toLocaleString()}

-
- {!e.acknowledged && ( - - )} -
-
+ setExpandedId((id) => (id === e.id ? null : e.id))} + onDismiss={e.acknowledged ? undefined : () => void acknowledge(e.id)} + /> ))}
)} diff --git a/app/src/app/confluence/page.tsx b/app/src/app/confluence/page.tsx index b20e28c..983098f 100644 --- a/app/src/app/confluence/page.tsx +++ b/app/src/app/confluence/page.tsx @@ -13,6 +13,9 @@ const TABS = [ * Confluence — entry/exit picture quality from the confluence rack, plus the * Price Corridor view (valuation corridor + corridor-method backtest ledger). * Evidence-based descriptions of a symbol's current setup (ADR-0007). + * + * Both tabs stay mounted and follow the shell's active symbol. Switching tabs + * must not remount a panel onto a default ticker. */ export default function ConfluencePage() { const [tab, setTab] = useState<'confluence' | 'corridor'>('confluence'); @@ -33,7 +36,12 @@ export default function ConfluencePage() { ))}
- {tab === 'confluence' ? : } + + ); } \ No newline at end of file diff --git a/app/src/app/funds/[id]/page.tsx b/app/src/app/funds/[id]/page.tsx index 4f18d6d..871a870 100644 --- a/app/src/app/funds/[id]/page.tsx +++ b/app/src/app/funds/[id]/page.tsx @@ -4,14 +4,20 @@ import { useEffect, useState, Fragment } from "react"; import { useParams } from "next/navigation"; import Link from "next/link"; import { ExternalLink } from "lucide-react"; -import { api } from "@/lib/trpc"; +import { api, type FundFreshness } from "@/lib/trpc"; import { LayoutShell } from "@/components/LayoutShell"; import { UI_STRINGS } from "@/lib/strings"; +import { LastUpdated } from "@/components/shared/LastUpdated"; interface LivePos { symbol: string; shares: number | null; value_usd: number | null; cost_basis: number | null; as_of: string; source: string; evidence_url: string | null; notes: string | null; post_text: string | null; + status?: "held" | "exited"; last_held_shares?: number | null; + prior_shares?: number | null; share_delta?: number | null; + change?: "add" | "cut" | "flat" | "new" | null; + x_only?: boolean; + evidence_status?: "ok" | "deleted" | "unknown"; } interface DiffRow { symbol: string; weight: number; fund_avg_cost: number | null; target_value: number; @@ -27,6 +33,7 @@ const SOURCE_BADGE: Record = { capture: "bg-emerald-100 text-emerald-800", claim: "bg-sky-100 text-sky-800", "13f": "bg-gray-100 text-gray-700", + "x only": "bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-200", }; function tweetUrl(evidenceUrl: string | null): string | null { @@ -40,18 +47,29 @@ export default function FundDetailPage() { const params = useParams<{ id: string }>(); const id = params.id; const [book, setBook] = useState([]); + const [bookLoading, setBookLoading] = useState(true); + const [bookError, setBookError] = useState(null); const [fundName, setFundName] = useState(""); + const [freshness, setFreshness] = useState(null); const [base, setBase] = useState(200000); const [floorOn, setFloorOn] = useState(true); const [diff, setDiff] = useState(null); useEffect(() => { if (!id) return; - api.funds.get({ id }).then((r: unknown) => { - const f = r as { fund_name?: string } | null; - if (f?.fund_name) setFundName(f.fund_name); + setBookLoading(true); + setBookError(null); + api.funds.get({ id }).then((r) => { + if (r?.fund_name) setFundName(r.fund_name); + setFreshness(r?.freshness ?? null); }).catch(() => {}); - api.funds.liveBook({ fundId: id }).then((r: unknown) => setBook((r as LivePos[]) ?? [])).catch(() => {}); + api.funds.liveBook({ fundId: id }) + .then((r: unknown) => setBook((r as LivePos[]) ?? [])) + .catch((e) => { + setBook([]); + setBookError(e instanceof Error ? e.message : "Failed to load book"); + }) + .finally(() => setBookLoading(false)); }, [id]); const compare = async () => { @@ -66,12 +84,105 @@ export default function FundDetailPage() { }; // Deduplicate to most recent entry per symbol (fixes React duplicate-key warning) + const instKey = (p: LivePos) => `${p.symbol}::${p.notes === "call" || p.notes === "put" ? p.notes : "eq"}`; const sortedBook = [...book] .sort((a, b) => b.as_of.localeCompare(a.as_of)) - .filter((p, i, arr) => arr.findIndex((q) => q.symbol === p.symbol) === i); - const resetIdx = sortedBook.findIndex((p) => p.notes === 'book_reset'); + .filter((p, i, arr) => arr.findIndex((q) => instKey(q) === instKey(p)) === i); + const heldBook = sortedBook.filter((p) => p.status !== "exited" && p.shares !== 0); + const exitedBook = sortedBook.filter((p) => p.status === "exited" || p.shares === 0); + const resetIdx = heldBook.findIndex((p) => p.notes === 'book_reset'); const [tip, setTip] = useState<{ symbol: string; text: string; x: number; y: number } | null>(null); + const changeLabel = (p: LivePos) => { + if (p.change === "new") return { text: "new", cls: "text-fg-muted" }; + if (p.change === "flat") return { text: "flat", cls: "text-fg-muted" }; + if (p.change === "add" && p.share_delta != null) { + return { text: `+${p.share_delta.toLocaleString()}`, cls: "text-emerald-700" }; + } + if (p.change === "cut" && p.share_delta != null) { + return { text: p.share_delta.toLocaleString(), cls: "text-red-700" }; + } + return { text: "—", cls: "text-fg-muted" }; + }; + + const renderRow = (p: LivePos, i: number, list: LivePos[], muted = false) => ( + + {resetIdx >= 0 && list === heldBook && i === resetIdx && ( + + +
+
+ + Book reset — sold almost everything, kept 6 names + +
+
+ + + )} + { + if (!p.post_text) return; + const rect = e.currentTarget.getBoundingClientRect(); + const maxW = Math.min(448, window.innerWidth - 32); + const x = Math.min(rect.left, window.innerWidth - maxW - 16); + const below = rect.bottom + 4; + const y = below + 220 > window.innerHeight ? Math.max(8, rect.top - 224) : below; + setTip({ symbol: p.symbol, text: p.post_text, x: Math.max(8, x), y }); + }} + onMouseLeave={() => setTip(null)} + onClick={() => { + if (p.evidence_status === "deleted") return; + const url = tweetUrl(p.evidence_url); + if (url) window.open(url, '_blank', 'noopener,noreferrer'); + }} + > + + + {p.symbol} + {muted && ( + + exited + + )} + {(p.notes === 'call' || p.notes === 'put') && ( + + {p.notes} + + )} + {tweetUrl(p.evidence_url) && p.evidence_status !== "deleted" && ( + + )} + {p.evidence_status === "deleted" && ( + + deleted + + )} + + + + {muted + ? (p.last_held_shares != null ? p.last_held_shares.toLocaleString() : p.shares != null && p.shares > 0 ? p.shares.toLocaleString() : "—") + : p.shares != null ? p.shares.toLocaleString() : "—"} + + + {muted ? "—" : ( + {changeLabel(p).text} + )} + + {p.cost_basis != null ? `$${p.cost_basis}` : "—"} + {p.as_of} + + + {p.x_only ? "x only" : p.source} + {p.evidence_status === "deleted" ? " · deleted" : ""} + + + +
+ ); + return (
@@ -82,83 +193,80 @@ export default function FundDetailPage() {

{fundName || "Fund profile"}

Disclosed book as reported by the fund. Figures are arithmetic derived from disclosed - holdings — this is a calculator, not advice. + holdings - this is a calculator, not advice.

+
+ {freshness?.xHandle && ( + + )} + {freshness?.lastPostAt && ( + + )} + + +
-

Live book (most recent disclosure per position)

+

Disclosed book

+

+ Latest non-zero disclosure per name. Change is vs the prior disclosure of the same instrument (common vs calls). + Source x only means the name is on X and has never appeared in a 13F for that instrument. +

- + - {sortedBook.map((p, i) => ( - - {resetIdx >= 0 && i === resetIdx && ( - - - - )} - { - if (!p.post_text) return; - const rect = e.currentTarget.getBoundingClientRect(); - const maxW = Math.min(448, window.innerWidth - 32); - const x = Math.min(rect.left, window.innerWidth - maxW - 16); - // Prefer below the row; flip above if near bottom of viewport. - const below = rect.bottom + 4; - const y = below + 220 > window.innerHeight ? Math.max(8, rect.top - 224) : below; - setTip({ symbol: p.symbol, text: p.post_text, x: Math.max(8, x), y }); - }} - onMouseLeave={() => setTip(null)} - onClick={() => { - const url = tweetUrl(p.evidence_url); - if (url) window.open(url, '_blank', 'noopener,noreferrer'); - }} - > - - - - - - - - ))} - {book.length === 0 && } + {heldBook.map((p, i) => renderRow(p, i, heldBook))} + {heldBook.length === 0 && ( + + + + )}
SymbolSharesAvg costAs ofSourceSymbolSharesChangeAvg costAs ofSource
-
-
- - Book reset — sold almost everything, kept 6 names - -
-
-
- - {p.symbol} - {(p.notes === 'call' || p.notes === 'put') && ( - - {p.notes} - - )} - {tweetUrl(p.evidence_url) && ( - - )} - - {p.shares != null ? p.shares.toLocaleString() : "—"}{p.cost_basis != null ? `$${p.cost_basis}` : "—"}{p.as_of} - - {p.source} - -
No disclosures yet.
+ {bookLoading ? "Loading book…" : bookError ? bookError : "No disclosures yet."} +
+ {exitedBook.length > 0 && ( +
+

Exited

+

+ Omitted from the latest 13F quarter-end snapshot, or the manager reported the position closed on X. + A 13F omission is not a live sale ticket. +

+ + + + + + + + {exitedBook.map((p, i) => renderRow(p, i, exitedBook, true))} + +
SymbolLast sharesChangeAvg costAs ofSource
+
+ )} +
-

Mirror target

+

Tracked fund book

+
+ {f.x_handle && ( + + )} + {f.freshness?.lastPostAt && ( + + )} + +
diff --git a/app/src/app/health/route.ts b/app/src/app/health/route.ts new file mode 100644 index 0000000..993d882 --- /dev/null +++ b/app/src/app/health/route.ts @@ -0,0 +1,5 @@ +export const dynamic = "force-dynamic"; + +export function GET() { + return Response.json({ ok: true, service: "investor-flow-web" }); +} diff --git a/app/src/app/more/page.tsx b/app/src/app/more/page.tsx index 3c4cb91..dbba306 100644 --- a/app/src/app/more/page.tsx +++ b/app/src/app/more/page.tsx @@ -20,7 +20,7 @@ const LINKS: MoreLink[] = [ { label: "Get Started", href: "/guided-start", description: "Strategy templates for how you manage the book", module: "execution", navId: "guided-start" }, { label: "Portfolio", href: "/portfolio", description: "Holdings, marks, and book management", module: "execution", navId: "portfolio" }, { label: "Risk", href: "/risk", description: "Risk posture and sizing math", module: "execution", navId: "risk" }, - { label: "Tracked Funds", href: "/funds", description: "Mirror a tracked fund's disclosed book", module: "execution", navId: "funds" }, + { label: "Tracked Funds", href: "/funds", description: "A tracked fund's disclosed book", module: "execution", navId: "funds" }, { label: "Decision plan", href: "/plan", description: "Record planned or active position decisions", module: "execution", navId: "plan" }, { label: "Theses", href: "/theses", description: "Why you own a name and what invalidates it", module: "execution", navId: "theses" }, { label: "Journal", href: "/journal", description: "Close notes and decision log", module: "execution", navId: "journal" }, diff --git a/app/src/components/AlertEventCard.tsx b/app/src/components/AlertEventCard.tsx new file mode 100644 index 0000000..82cc041 --- /dev/null +++ b/app/src/components/AlertEventCard.tsx @@ -0,0 +1,145 @@ +"use client"; + +import { ChevronDown, ExternalLink as ExternalLinkIcon } from "lucide-react"; +import type { AlertEventRow } from "@/lib/trpc"; + +const SEVERITY_COLORS: Record = { + info: "text-blue-400 bg-blue-950/30 border-blue-500/30", + warning: "text-amber-400 bg-amber-950/30 border-amber-500/30", + critical: "text-red-400 bg-red-950/30 border-red-500/30", +}; + +export function alertMessageNeedsExpand(description: string): boolean { + return description.includes("\n") || description.length > 140; +} + +interface AlertEventCardProps { + alert: AlertEventRow; + expanded: boolean; + onToggle: () => void; + onDismiss?: () => void; + compact?: boolean; +} + +export function AlertEventCard({ + alert, + expanded, + onToggle, + onDismiss, + compact = false, +}: AlertEventCardProps) { + const expandable = alertMessageNeedsExpand(alert.description); + const showFull = !expandable || expanded; + const filingUrl = typeof alert.payload?.url === "string" ? alert.payload.url : null; + + return ( +
+
+
+ {expandable ? ( + + ) : ( + <> + + + + )} + {filingUrl ? ( + + View filing on SEC.gov + + + ) : null} +

+ {compact + ? new Date(alert.createdAt).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }) + : new Date(alert.createdAt).toLocaleString()} +

+
+ {onDismiss ? ( + + ) : null} +
+
+ ); +} + +function AlertHeader({ alert, compact }: { alert: AlertEventRow; compact: boolean }) { + return ( +
+ + {alert.severity} + + {alert.title} + {alert.symbol ? ( + + {alert.symbol} + + ) : null} +
+ ); +} + +function AlertDescription({ + alert, + compact, + showFull, +}: { + alert: AlertEventRow; + compact: boolean; + showFull: boolean; +}) { + return ( +

+ {alert.description} +

+ ); +} + +function ExpandHint({ expanded, compact }: { expanded: boolean; compact: boolean }) { + return ( + + + {expanded ? "Show less" : "Show more"} + + ); +} diff --git a/app/src/components/ChartLabPanel.tsx b/app/src/components/ChartLabPanel.tsx index 36c44f8..63e7cb2 100644 --- a/app/src/components/ChartLabPanel.tsx +++ b/app/src/components/ChartLabPanel.tsx @@ -1,10 +1,14 @@ "use client"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { useActiveSymbol } from "@/stores/active-symbol-store"; import { api, type PriceCandle, type IndicatorResult } from "@/lib/trpc"; import { UI_STRINGS } from "@/lib/strings"; import { chart as CHART } from "@/lib/chart-theme"; import { buildVolumeProfile } from "@/lib/volumeProfile"; +import { emaRelation, priceExtents } from "@/lib/priceChartGeometry"; +import { computeEma921Range } from "@/lib/ema921Range"; +import { PriceCandleLayer } from "@/components/chart/PriceCandleLayer"; +import { LastUpdated } from "@/components/shared/LastUpdated"; import { Bar, BarChart, @@ -21,32 +25,61 @@ import { // Chart Lab Panel (Slice 5c). Renders price candles, EMA overlays, volume, RSI, // relative volume, and equity volume-by-price for the active symbol. +type ChartTf = '1m' | '5m' | '1d' | '1wk' | '1mo'; + interface RangeOption { label: string; - years: number | null; // null = ALL + years: number | null; + days?: number | null; } -const RANGE_OPTIONS: RangeOption[] = [ + +const DAILY_RANGE: RangeOption[] = [ { label: "3M", years: 0.25 }, { label: "6M", years: 0.5 }, { label: "1Y", years: 1 }, { label: "5Y", years: 5 }, { label: "ALL", years: null }, ]; +const RANGE_1M: RangeOption[] = [ + { label: "1D", years: null, days: 1 }, + { label: "2D", years: null, days: 2 }, + { label: "5D", years: null, days: 5 }, + { label: "ALL", years: null, days: null }, +]; +const RANGE_5M: RangeOption[] = [ + { label: "1D", years: null, days: 1 }, + { label: "5D", years: null, days: 5 }, + { label: "1M", years: null, days: 21 }, + { label: "ALL", years: null, days: null }, +]; -// Convert a range (in years) to a number of candles for the active timeframe, -// so the Range labels stay meaningful across Daily / Weekly / Monthly. -function barsForRange(years: number | null, timeframe: '1d' | '1wk' | '1mo'): number { - if (years === null) return Infinity; +function rangesFor(tf: ChartTf): RangeOption[] { + if (tf === '1m') return RANGE_1M; + if (tf === '5m') return RANGE_5M; + return DAILY_RANGE; +} + +function defaultRange(tf: ChartTf): string { + if (tf === '1m') return '1D'; + if (tf === '5m') return '5D'; + return '1Y'; +} + +function barsForYears(years: number | null, timeframe: ChartTf): number { + if (years == null) return Infinity; const perYear = timeframe === '1mo' ? 12 : timeframe === '1wk' ? 52 : 252; return Math.round(years * perYear); } -function formatCandleDate(ts: string): string { +function formatCandleDate(ts: string, tf: ChartTf): string { try { const d = new Date(ts); + if (tf === '1m' || tf === '5m') { + return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); + } return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); } catch { - return ts.slice(0, 10); + return ts.slice(0, 16); } } @@ -80,6 +113,8 @@ interface ChartDataPoint { macdLine?: number; signalLine?: number; histogram?: number; + sentiment?: 'above' | 'below' | 'between' | null; + cross?: 'up' | 'down' | null; } interface BuyEvent { @@ -89,19 +124,19 @@ interface BuyEvent { date: string; } -type SeriesKey = 'close' | 'ema9' | 'ema21' | 'ema50' | 'ema200' | 'instBuys'; +type SeriesKey = 'candles' | 'ema9' | 'ema21' | 'ema50' | 'ema200' | 'instBuys'; const SERIES_LABELS: Record = { - close: 'Close', - ema9: 'EMA 9', - ema21: 'EMA 21', - ema50: 'EMA 50', - ema200: 'EMA 200', + candles: UI_STRINGS.candlesLabel, + ema9: UI_STRINGS.ema9Label, + ema21: UI_STRINGS.ema21Label, + ema50: UI_STRINGS.ema50Label, + ema200: UI_STRINGS.ema200Label, instBuys: 'Institutional Buys', }; const SERIES_COLORS: Record = { - close: CHART.accent, + candles: CHART.accent, ema9: CHART.emaColors[9], ema21: CHART.emaColors[21], ema50: CHART.emaColors[50], @@ -109,47 +144,101 @@ const SERIES_COLORS: Record = { instBuys: '#34d399', }; +function PriceTooltip({ + active, + payload, + label, + visible, +}: { + active?: boolean; + payload?: Array<{ payload: ChartDataPoint }>; + label?: string; + visible: Record; +}) { + if (!active || !payload?.length) return null; + const d = payload[0]!.payload; + const row = (k: string, v: string, color?: string) => ( +
+ {k} + {v} +
+ ); + return ( +
+
{d.date}
+ {row("Open", `$${d.o.toFixed(2)}`)} + {row("High", `$${d.h.toFixed(2)}`)} + {row("Low", `$${d.l.toFixed(2)}`)} + {row("Close", `$${d.c.toFixed(2)}`)} + {visible.ema9 && d.ema9 != null && row(UI_STRINGS.ema9Label, `$${d.ema9.toFixed(2)}`, SERIES_COLORS.ema9)} + {visible.ema21 && d.ema21 != null && row(UI_STRINGS.ema21Label, `$${d.ema21.toFixed(2)}`, SERIES_COLORS.ema21)} + {visible.ema50 && d.ema50 != null && row(UI_STRINGS.ema50Label, `$${d.ema50.toFixed(2)}`, SERIES_COLORS.ema50)} + {visible.ema200 && d.ema200 != null && row(UI_STRINGS.ema200Label, `$${d.ema200.toFixed(2)}`, SERIES_COLORS.ema200)} + {visible.instBuys && d.instVol > 0 && row("Inst. volume", formatShares(d.instVol), SERIES_COLORS.instBuys)} + {d.cross === "up" && row(UI_STRINGS.ema921CrossUp, "")} + {d.cross === "down" && row(UI_STRINGS.ema921CrossDown, "")} +
+ ); +} export function ChartLabPanel() { const symbol = useActiveSymbol((s) => s.activeSymbol); - const [timeframe, setTimeframe] = useState<'1d' | '1wk' | '1mo'>('1d'); + const [timeframe, setTimeframe] = useState('1d'); const [candles, setCandles] = useState([]); + const [candlesFetchedAt, setCandlesFetchedAt] = useState(null); const [indicators, setIndicators] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [buyEvents, setBuyEvents] = useState(null); const [visibleSeries, setVisibleSeries] = useState>({ - close: true, ema9: true, ema21: true, ema50: true, ema200: true, instBuys: false, + candles: true, ema9: true, ema21: true, ema50: false, ema200: false, instBuys: false, }); useEffect(() => { let cancelled = false; setLoading(true); setError(null); + setRange(defaultRange(timeframe)); - const loadCandles = api.market.candles(symbol, timeframe).catch((e) => { - if (!cancelled) setError(e instanceof Error ? e.message : "request failed"); - return null; - }); - const loadIndicators = api.market.indicators(symbol, { - periods: { ema: [9, 21, 50, 200], rsi: 14, relvol: 20 }, - timeframe, - }).catch((e) => { - if (!cancelled) setError(e instanceof Error ? e.message : "request failed"); - return null; - }); - const loadBuyEvents = api.institutional.buyEvents(symbol).catch(() => null); - - Promise.all([loadCandles, loadIndicators, loadBuyEvents]).then(([cData, iData, bData]) => { - if (!cancelled) { - setCandles(Array.isArray(cData) ? cData : []); + const load = (first: boolean) => { + const loadCandles = api.market.candlesWithMeta(symbol, timeframe).catch((e) => { + if (!cancelled && first) setError(e instanceof Error ? e.message : "request failed"); + return null; + }); + const loadIndicators = api.market.indicators(symbol, { + periods: { ema: [9, 21, 50, 200], rsi: 14, relvol: 20 }, + timeframe, + }).catch((e) => { + if (!cancelled && first) setError(e instanceof Error ? e.message : "request failed"); + return null; + }); + const loadBuyEvents = api.institutional.buyEvents(symbol).catch(() => null); + return Promise.all([loadCandles, loadIndicators, loadBuyEvents]).then(([cData, iData, bData]) => { + if (cancelled) return 0; + const next = Array.isArray(cData?.candles) ? cData.candles : []; + setCandles(next); + setCandlesFetchedAt(cData?.fetchedAt ?? null); setIndicators(iData); setBuyEvents(bData?.events ?? null); setLoading(false); - } - }); + return next.length; + }); + }; - return () => { cancelled = true; }; + void load(true); + const minute = timeframe === '1m' || timeframe === '5m'; + if (!minute) return () => { cancelled = true; }; + const timer = window.setInterval(() => { + void load(false).then((n) => { + if (n > 0) window.clearInterval(timer); + }); + }, 3000); + const stop = window.setTimeout(() => window.clearInterval(timer), 120_000); + return () => { + cancelled = true; + window.clearInterval(timer); + window.clearTimeout(stop); + }; }, [symbol, timeframe]); // Build quarterly institutional buy volume lookup from buy events (13F = quarterly) @@ -186,7 +275,7 @@ export function ChartLabPanel() { const chartData: ChartDataPoint[] = useMemo(() => { return rawCandles.map((c, i) => { const d: Record = { - date: formatCandleDate(c.ts), + date: formatCandleDate(c.ts, timeframe), ts: c.ts, o: c.o, h: c.h, @@ -211,7 +300,17 @@ export function ChartLabPanel() { } return d as ChartDataPoint; }); - }, [rawCandles, rsiSeries, relvolSeries, macdLineSeries, signalLineSeries, histogramSeries, emaArrays, buyVolByQuarter]); + }, [rawCandles, rsiSeries, relvolSeries, macdLineSeries, signalLineSeries, histogramSeries, emaArrays, buyVolByQuarter, timeframe]); + + const rangeModel = useMemo(() => computeEma921Range(rawCandles), [rawCandles]); + + const chartDataWithRange: ChartDataPoint[] = useMemo(() => { + return chartData.map((d, i) => { + const b = rangeModel.bars[i]; + if (!b) return d; + return { ...d, sentiment: b.sentiment, cross: b.cross }; + }); + }, [chartData, rangeModel]); function formatVolume(value: number): string { if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; @@ -223,12 +322,23 @@ export function ChartLabPanel() { // only the displayed chart slice is trimmed. Defaults to 1Y. const [range, setRange] = useState("1Y"); const [showVolumeProfile, setShowVolumeProfile] = useState(true); + const rangeOptions = rangesFor(timeframe); const displayData: ChartDataPoint[] = useMemo(() => { - if (!chartData.length) return []; - const option = RANGE_OPTIONS.find((o) => o.label === range); - const maxBars = barsForRange(option?.years ?? null, timeframe); - return chartData.slice(-maxBars); - }, [chartData, range, timeframe]); + if (!chartDataWithRange.length) return []; + const option = rangeOptions.find((o) => o.label === range); + if (option?.days != null) { + const lastTs = chartDataWithRange[chartDataWithRange.length - 1]?.ts; + const end = lastTs ? Date.parse(lastTs) : NaN; + if (Number.isFinite(end)) { + const cut = end - option.days * 86_400_000; + return chartDataWithRange.filter((d) => Date.parse(d.ts) >= cut); + } + } + if (option?.years != null) { + return chartDataWithRange.slice(-barsForYears(option.years, timeframe)); + } + return chartDataWithRange; + }, [chartDataWithRange, range, timeframe, rangeOptions]); const volumeProfile = useMemo( () => @@ -245,30 +355,34 @@ export function ChartLabPanel() { return m || 1; }, [volumeProfile.bins]); - const tooltipFormatter = useCallback((value: any, name: string) => { - if (!name || name === "date") return [value, '']; - if (name === "c") return [`$${Number(value)?.toFixed(2)}`, "Close"]; - if (name === "instVol") return [formatShares(Number(value)), "Institutional Buys"]; - if (name.startsWith("ema")) { - const period = name.replace("ema", ""); - return [`$${Number(value)?.toFixed(2)}`, `${UI_STRINGS[`ema${period}Label` as keyof typeof UI_STRINGS] ?? `EMA ${period}`}`]; - } - return [value, name]; - }, []); + const lastBar = displayData[displayData.length - 1]; + const lastRelation = emaRelation(lastBar?.ema9, lastBar?.ema21); + const relationLabel = + lastRelation === "above" + ? UI_STRINGS.ema9Above21 + : lastRelation === "below" + ? UI_STRINGS.ema9Below21 + : lastRelation === "even" + ? UI_STRINGS.ema9Even21 + : null; - // Price chart — candlesticks rendered as raw SVG inside for correct pixel positioning, - // with EMA lines overlaid via recharts . Display window trimmed by the range selector. const { minP: priceMin, maxP: priceMax } = useMemo(() => { - if (!displayData.length) return { minP: 0, maxP: 1 }; - let mn = Infinity, mx = -Infinity; - for (const d of displayData) { - if (d.h > mx) mx = d.h; - if (d.l < mn) mn = d.l; - if (d.ema200 !== undefined && d.ema200 > mx) mx = d.ema200; - } - const pad = (mx - mn) * 0.1 || 1; - return { minP: mn - pad, maxP: mx + pad }; - }, [displayData]); + return priceExtents(displayData, { + ema9: visibleSeries.ema9, + ema21: visibleSeries.ema21, + ema50: visibleSeries.ema50, + ema200: visibleSeries.ema200, + extras: [ + rangeModel.levels.up1, + rangeModel.levels.up2, + rangeModel.levels.down1, + rangeModel.levels.down2, + ], + }); + }, [displayData, visibleSeries.ema9, visibleSeries.ema21, visibleSeries.ema50, visibleSeries.ema200, rangeModel.levels]); + + const upFromDate = rangeModel.levels.upFromIndex != null ? rawCandles[rangeModel.levels.upFromIndex]!.ts : null; + const downFromDate = rangeModel.levels.downFromIndex != null ? rawCandles[rangeModel.levels.downFromIndex]!.ts : null; const maxInstVol = useMemo(() => { let mx = 0; @@ -307,62 +421,148 @@ export function ChartLabPanel() {

{UI_STRINGS.chartsTitle}

-
- {(['1d', '1wk', '1mo'] as const).map((tf) => ( - ))}
-
- {RANGE_OPTIONS.map((o) => ( - - ))} + ); + })}
- {/* Price chart with EMA overlays + Institutional Buy Events */} + {/* Price chart: OHLC candles + EMA 9/21 ribbon (50/200 optional) */}
-

{UI_STRINGS.priceChartCaption}

-
+

+ {UI_STRINGS.priceChartCaption}{" "} + +

+ {!displayData.length && (timeframe === '1m' || timeframe === '5m') && ( +

{UI_STRINGS.fetchingMinuteBars}

+ )} + {lastBar && ( +
+ Last ${lastBar.c.toFixed(2)} + {lastBar.ema9 != null && ( + EMA 9 ${lastBar.ema9.toFixed(2)} + )} + {lastBar.ema21 != null && ( + EMA 21 ${lastBar.ema21.toFixed(2)} + )} + {relationLabel && ( + + {relationLabel} + + )} + {rangeModel.levels.up1 != null && ( + {UI_STRINGS.ema921Up1} ${rangeModel.levels.up1.toFixed(2)} + )} + {rangeModel.levels.up2 != null && ( + {UI_STRINGS.ema921Up2} ${rangeModel.levels.up2.toFixed(2)} + )} + {rangeModel.levels.down1 != null && ( + {UI_STRINGS.ema921Down1} ${rangeModel.levels.down1.toFixed(2)} + )} + {rangeModel.levels.down2 != null && ( + {UI_STRINGS.ema921Down2} ${rangeModel.levels.down2.toFixed(2)} + )} +
+ )} +
- + - + formatCandleDate(String(ts), timeframe)} + /> `$${v.toFixed(0)}`} /> - `Date: ${label}`} - formatter={tooltipFormatter as any} + } /> + - {visibleSeries.close && } - {visibleSeries.ema9 && } - {visibleSeries.ema21 && } - {visibleSeries.ema50 && } - {visibleSeries.ema200 && } + + {visibleSeries.ema9 && } + {visibleSeries.ema21 && } + {visibleSeries.ema50 && } + {visibleSeries.ema200 && } {visibleSeries.instBuys && }
- {/* Custom toggleable legend */}
{(Object.keys(SERIES_LABELS) as SeriesKey[]).map((key) => ( ))}
- {UI_STRINGS.emaTooltip} + {UI_STRINGS.emaTooltip} {UI_STRINGS.emaRibbonCaption}
diff --git a/app/src/components/ConfluenceChart.tsx b/app/src/components/ConfluenceChart.tsx index aab1844..d3cfe50 100644 --- a/app/src/components/ConfluenceChart.tsx +++ b/app/src/components/ConfluenceChart.tsx @@ -26,11 +26,12 @@ interface SignalFire { } interface ChartPoint { - date: string; + i: number; + dateKey: string; dateShort: string; close: number; - bullFires: SignalFire[]; - bearFires: SignalFire[]; + fires: SignalFire[]; + zone: 'entry' | 'exit' | null; } interface EntryExitZone { @@ -39,6 +40,32 @@ interface EntryExitZone { type: 'entry' | 'exit'; } +export interface ChartZone { + startAsOf: string; + endAsOf: string; + type: 'entry' | 'exit'; +} + +/** First four weeks from zone start - the follow-through window, not the whole stretch. */ +const ONSET_BARS = 20; + +function zoneKey(zones: ChartZone[]): string { + return zones.map((z) => `${z.type}:${z.startAsOf}:${z.endAsOf}`).join('|'); +} + +function candleCutoff(zones: ChartZone[]): string { + const sixMo = new Date(); + sixMo.setMonth(sixMo.getMonth() - 6); + let cut = sixMo.toISOString().slice(0, 10); + for (const z of zones) { + const pad = new Date(`${z.startAsOf.slice(0, 10)}T00:00:00Z`); + pad.setUTCDate(pad.getUTCDate() - 21); + const padded = pad.toISOString().slice(0, 10); + if (padded < cut) cut = padded; + } + return cut; +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -47,17 +74,21 @@ function toDateKey(ts: string): string { return ts.slice(0, 10); } -function formatDate(ts: string): string { +function formatTick(dateKey: string, spanYears: boolean): string { try { - return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + return new Date(`${dateKey}T12:00:00Z`).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + ...(spanYears ? { year: '2-digit' } : {}), + }); } catch { - return ts.slice(5, 10); + return dateKey.slice(5, 10); } } function formatDateFull(ts: string): string { try { - return new Date(ts).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); + return new Date(`${toDateKey(ts)}T12:00:00Z`).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); } catch { return ts.slice(0, 10); } @@ -82,20 +113,23 @@ const BODY_LABEL: Record = { function ChartTooltip({ active, payload }: { active?: boolean; payload?: Array<{ payload: ChartPoint }> }) { if (!active || !payload?.length) return null; const point = payload[0].payload; - const allFires = [...point.bullFires, ...point.bearFires]; - // Deduplicate by slot name (a slot can fire multiple times on the same day) const seen = new Set(); - const uniqueFires = allFires.filter((f) => { + const uniqueFires = point.fires.filter((f) => { if (seen.has(f.slotName)) return false; seen.add(f.slotName); return true; }); return (
-
{formatDateFull(point.date)}
+
{formatDateFull(point.dateKey)}
Close: ${point.close.toFixed(2)}
+ {point.zone && ( +
+ {point.zone === 'entry' ? 'Entry zone' : 'Exit zone'} +
+ )} {uniqueFires.length > 0 && (
{uniqueFires.map((f, i) => ( @@ -111,72 +145,43 @@ function ChartTooltip({ active, payload }: { active?: boolean; payload?: Array<{ ); } -// --------------------------------------------------------------------------- -// Custom dot renderer - renders colored markers for signal fires -// --------------------------------------------------------------------------- - -function SignalDot(props: { cx?: number; cy?: number; payload?: ChartPoint; index?: number }) { - const { cx, cy, payload } = props; - if (cx == null || cy == null || !payload) return null; - - const allFires = [...payload.bullFires, ...payload.bearFires]; - if (allFires.length === 0) return null; - - // Stack multiple markers vertically if there are several signals - const markers = allFires.slice(0, 4); // cap at 4 visible markers - return ( - - {markers.map((f, i) => ( - - ))} - - ); -} - // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- interface ConfluenceChartProps { symbol: string; + zones?: ChartZone[]; } -export function ConfluenceChart({ symbol }: ConfluenceChartProps) { +export function ConfluenceChart({ symbol, zones: overlayZones = [] }: ConfluenceChartProps) { const [candles, setCandles] = useState([]); const [fires, setFires] = useState([]); const [loading, setLoading] = useState(true); + const overlayKey = zoneKey(overlayZones); + useEffect(() => { let cancelled = false; setLoading(true); + const cutoffStr = candleCutoff(overlayZones); Promise.all([ api.market.candles(symbol, '1d'), api.confluence.signalHistory(symbol), ]).then(([candleData, fireData]) => { if (cancelled) return; - const cutoff = new Date(); - cutoff.setMonth(cutoff.getMonth() - 6); - const cutoffStr = cutoff.toISOString(); - setCandles(candleData.filter((c) => c.ts >= cutoffStr)); - setFires(fireData.fires); + setCandles(candleData.filter((c) => toDateKey(c.ts) >= cutoffStr)); + setFires(fireData.fires.filter((f) => toDateKey(f.firedAt) >= cutoffStr)); setLoading(false); }).catch(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; - }, [symbol]); + // overlayKey stands in for overlayZones so a new array identity does not refetch. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [symbol, overlayKey]); const { chartData, zones } = useMemo(() => { if (!candles.length) return { chartData: [], zones: [] }; @@ -189,10 +194,9 @@ export function ConfluenceChart({ symbol }: ConfluenceChartProps) { firesByDate.set(key, arr); } - const data: ChartPoint[] = candles.map((c) => { + const data: ChartPoint[] = candles.map((c, i) => { const key = toDateKey(c.ts); const dayFires = firesByDate.get(key) ?? []; - // Deduplicate by slot name per day (a slot can fire multiple times) const seenSlots = new Set(); const uniqueDayFires = dayFires.filter((f) => { if (seenSlots.has(f.slotId)) return false; @@ -200,35 +204,37 @@ export function ConfluenceChart({ symbol }: ConfluenceChartProps) { return true; }); return { - date: c.ts, - dateShort: formatDate(c.ts), + i, + dateKey: key, + dateShort: formatTick(key, false), close: c.c, - bullFires: uniqueDayFires.filter((f) => f.body === 'bull'), - bearFires: uniqueDayFires.filter((f) => f.body === 'bear' || f.body === 'exit'), + fires: uniqueDayFires, + zone: null, }; }); - // Detect entry/exit zones: 2+ signals of same direction within 5 trading days - const windowSize = 5; + const indexByDate = new Map(); + data.forEach((p, i) => indexByDate.set(p.dateKey, i)); + const rawZones: EntryExitZone[] = []; - for (let i = 0; i < data.length; i++) { - const windowEnd = Math.min(i + windowSize, data.length); - let bullCount = 0; - let bearCount = 0; - for (let j = i; j < windowEnd; j++) { - bullCount += data[j].bullFires.length; - bearCount += data[j].bearFires.length; - } - if (bullCount >= 2 && !rawZones.some((z) => z.type === 'entry' && i >= z.startIdx && i <= z.endIdx)) { - rawZones.push({ startIdx: i, endIdx: Math.min(i + windowSize - 1, data.length - 1), type: 'entry' }); - } - if (bearCount >= 2 && !rawZones.some((z) => z.type === 'exit' && i >= z.startIdx && i <= z.endIdx)) { - rawZones.push({ startIdx: i, endIdx: Math.min(i + windowSize - 1, data.length - 1), type: 'exit' }); + for (const z of overlayZones) { + const startIdx = indexByDate.get(toDateKey(z.startAsOf)); + // Onset is the event. If it is off-screen, do not flood the visible range. + if (startIdx == null) continue; + const naturalEnd = indexByDate.get(toDateKey(z.endAsOf)) ?? data.length - 1; + const endIdx = Math.min(naturalEnd, startIdx + ONSET_BARS - 1, data.length - 1); + if (endIdx < startIdx) continue; + rawZones.push({ startIdx, endIdx, type: z.type }); + } + + for (const z of rawZones) { + for (let i = z.startIdx; i <= z.endIdx; i++) { + if (data[i].zone === null) data[i].zone = z.type; } } return { chartData: data, zones: rawZones }; - }, [candles, fires]); + }, [candles, fires, overlayZones]); if (loading) { return ( @@ -248,25 +254,14 @@ export function ConfluenceChart({ symbol }: ConfluenceChartProps) { return (
- {/* Legend */}
- Signal markers: - Bull + Entry zone - Bear - - - Exit - - | - - Entry zone - - - Exit zone + Exit zone + Bands mark the first four weeks of each window. Hover a session for the slots that were lit that day.
{/* Chart */} @@ -280,12 +275,21 @@ export function ConfluenceChart({ symbol }: ConfluenceChartProps) { { + const first = chartData[0]?.dateKey; + const last = chartData[chartData.length - 1]?.dateKey; + const spanYears = !!first && !!last && first.slice(0, 4) !== last.slice(0, 4); + const key = chartData[idx]?.dateKey; + return key ? formatTick(key, spanYears) : ''; + }} /> ( ))} - {/* Price area with signal markers */} } + dot={false} activeDot={{ r: 3, fill: CHART.accent }} /> diff --git a/app/src/components/ConfluencePanel.tsx b/app/src/components/ConfluencePanel.tsx index c7e3867..46ce6fc 100644 --- a/app/src/components/ConfluencePanel.tsx +++ b/app/src/components/ConfluencePanel.tsx @@ -1,9 +1,8 @@ 'use client'; -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect } from 'react'; import { api } from '@/lib/trpc'; import { useActiveSymbol } from '@/stores/active-symbol-store'; import { CollapsibleSection } from '@/components/CollapsibleSection'; -import { SymbolAutocomplete } from '@/components/SymbolAutocomplete'; import { ConfluenceChart } from '@/components/ConfluenceChart'; // --------------------------------------------------------------------------- @@ -40,35 +39,72 @@ const BODY_BADGE: Record = { exit: 'bg-amber-600/15 text-amber-400', }; +type ZoneRow = { + kind: 'entry' | 'exit'; + startAsOf: string; + endAsOf: string; + open: boolean; + qualityAtStart: string; + netEvidenceAtStart: number; + firedSlotIds: string[]; + forwardReturn: number | null; + verdict: 'confirmed' | 'false_alarm' | 'open'; +}; + +type ZoneRuleCard = { + label: string; + source: string; + derivedAt: string | null; + sampleCaveat: string; + train: { hitRate: number | null; resolved: number; baselineHitRate: number | null } | null; + validate: { hitRate: number | null; resolved: number; baselineHitRate: number | null } | null; +}; + +function formatPct(v: number | null | undefined): string { + if (v == null || !Number.isFinite(v)) return '-'; + return `${(v * 100).toFixed(0)}%`; +} + +function formatRet(v: number | null | undefined): string { + if (v == null || !Number.isFinite(v)) return '-'; + const sign = v > 0 ? '+' : ''; + return `${sign}${(v * 100).toFixed(1)}%`; +} + +function windowLabel(z: ZoneRow): string { + const end = z.open ? `${z.endAsOf} (open)` : z.endAsOf; + return z.startAsOf === z.endAsOf && !z.open ? z.startAsOf : `${z.startAsOf} - ${end}`; +} + // --------------------------------------------------------------------------- // Panel // --------------------------------------------------------------------------- export function ConfluencePanel() { - const activeSymbol = useActiveSymbol((s) => s.activeSymbol); - const setActiveSymbol = useActiveSymbol((s) => s.setActive); - const [symbol, setSymbol] = useState(activeSymbol); - const [searchQuery, setSearchQuery] = useState(activeSymbol); + const symbol = useActiveSymbol((s) => s.activeSymbol); const [selectedRackId, setSelectedRackId] = useState(null); + const [evaluation, setEvaluation] = useState | null>(null); + const [scorecard, setScorecard] = useState<{ slots: Array>; note: string } | null>(null); + const [recent, setRecent] = useState<{ + entries: ZoneRow[]; + exits: ZoneRow[]; + entryRule: ZoneRuleCard; + exitRule: ZoneRuleCard; + coverage: { evaluatedDays: number; lookbackDays: number; replayComplete: boolean }; + symbolScore: { + entry: { zones: number; resolved: number; confirmed: number; hitRate: number | null }; + exit: { zones: number; resolved: number; confirmed: number; hitRate: number | null }; + }; + learningLedger: { ready: boolean; filled: number; total: number }; + } | null>(null); - const pickSymbol = useCallback((sym: string) => { - setSymbol(sym); - setSearchQuery(sym); - setActiveSymbol(sym); - // Clear stale data immediately so old symbol never flashes + // Clear stale rows as soon as the shell ticker changes so the previous + // symbol never flashes under the new one. + useEffect(() => { setEvaluation(null); setScorecard(null); - }, [setActiveSymbol]); - - // Sync searchQuery when activeSymbol changes externally (e.g., from another panel) - useEffect(() => { - setSearchQuery(activeSymbol); - }, [activeSymbol]); - - // Sync symbol state when activeSymbol changes externally - useEffect(() => { - setSymbol(activeSymbol); - }, [activeSymbol]); + setRecent(null); + }, [symbol]); // Racks const [racks, setRacks] = useState<{ system: Array<{ id: string; name: string }>; user: Array<{ id: string; name: string }> }>({ system: [], user: [] }); @@ -76,8 +112,6 @@ export function ConfluencePanel() { api.confluence.racks().then(setRacks).catch(() => {}); }, []); - // Evaluation - const [evaluation, setEvaluation] = useState | null>(null); const rackId = selectedRackId ?? racks.system[0]?.id ?? null; useEffect(() => { if (!symbol) return; @@ -88,8 +122,50 @@ export function ConfluencePanel() { return () => { cancelled = true; }; }, [symbol, rackId]); - // Scorecard - const [scorecard, setScorecard] = useState<{ slots: Array>; note: string } | null>(null); + useEffect(() => { + if (!symbol) return; + let cancelled = false; + const load = () => { + api.confluence.recentZones(symbol, { rackId: rackId ?? undefined, entries: 3, exits: 3 }) + .then((r) => { + if (cancelled) return; + setRecent({ + entries: r.entries, + exits: r.exits, + entryRule: r.entryRule, + exitRule: r.exitRule, + coverage: r.coverage, + symbolScore: r.symbolScore, + learningLedger: r.learningLedger, + }); + }) + .catch(() => { if (!cancelled) setRecent(null); }); + }; + load(); + const timer = window.setInterval(() => { + if (cancelled) return; + api.confluence.recentZones(symbol, { rackId: rackId ?? undefined, entries: 3, exits: 3 }) + .then((r) => { + if (cancelled) return; + setRecent({ + entries: r.entries, + exits: r.exits, + entryRule: r.entryRule, + exitRule: r.exitRule, + symbolScore: r.symbolScore, + learningLedger: r.learningLedger, + coverage: r.coverage, + }); + if (r.coverage.replayComplete) window.clearInterval(timer); + }) + .catch(() => {}); + }, 8000); + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [symbol, rackId]); + useEffect(() => { if (!symbol) return; let cancelled = false; @@ -131,27 +207,13 @@ export function ConfluencePanel() { {/* Explanatory text */}
Signal Confluence combines technical, seasonal, and valuation signals into a single - directional read. Each slot fires (green dot) when its condition is met, or stays - dormant. The overall quality grade reflects how many slots fired and in which direction. - Use the rack selector to switch between different signal subsets. + directional read. The timeline shows the last few entry and exit zones. Hover a session + to see which slots were lit that day. The quality grade reflects how many slots fired + and in which direction. Use the rack selector to switch between different signal subsets.
- {/* Header */} + {/* Rack selector — ticker lives in the shell header */}
-
- -
- pickSymbol(raw.toUpperCase())} - placeholder="Search symbol…" - ariaLabel="Select symbol for signal confluence" - inputClassName="w-full bg-surface-sunken border border-line rounded-lg px-3 py-1.5 text-xs text-fg placeholder:text-fg-muted focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent" - /> -
-