From b724eb1ccde29c197b6ecd53b64caa2ca43b7832 Mon Sep 17 00:00:00 2001 From: Bruzzz BackUp <149516937+BWJ2310-backup@users.noreply.github.com> Date: Mon, 25 May 2026 13:02:48 -0600 Subject: [PATCH 1/5] feat(records): refresh order table and details panel (#127) * feat(trading): refresh order records table and details Co-authored-by: Codex * feat(records): keep order details accessible in linked log view Co-authored-by: Codex * refactor(trading): consolidate provider order detail types Co-authored-by: Codex --------- Co-authored-by: Codex --- .../orders/[orderId]/provider-detail/route.ts | 7 +- .../app/api/orders/[orderId]/route.test.ts | 2 +- .../tradinggoose/app/api/orders/route.test.ts | 2 +- .../components/log-details/log-details.tsx | 3 + .../records/components/orders/index.ts | 1 - .../components/orders/order-details.test.tsx | 171 +++++- .../components/orders/order-details.tsx | 441 ++++++++++---- .../components/orders/order-formatters.ts | 17 + .../orders/order-provider-refresh.tsx | 67 --- .../components/orders/order-row-actions.tsx | 85 +-- .../components/orders/orders-table.test.tsx | 62 +- .../components/orders/orders-table.tsx | 560 ++++++++++-------- .../records/components/orders/types.ts | 2 +- .../[workspaceId]/records/records.test.tsx | 6 +- .../[workspaceId]/records/records.tsx | 10 +- .../hooks/queries/records-orders.ts | 11 +- .../lib/records/order-filters.test.ts | 3 +- .../tradinggoose/lib/records/order-filters.ts | 2 +- apps/tradinggoose/lib/trading/order-detail.ts | 21 +- .../lib/trading/order-records.test.ts | 28 +- .../tradinggoose/lib/trading/order-records.ts | 17 +- .../providers/trading/alpaca/config.ts | 5 + .../trading/alpaca/orderDetail.test.ts | 7 + .../providers/trading/alpaca/orderDetail.ts | 2 +- .../providers/trading/providers.ts | 10 +- .../providers/trading/tradier/orderDetail.ts | 8 +- apps/tradinggoose/providers/trading/types.ts | 36 +- apps/tradinggoose/tools/trading/types.ts | 29 +- 28 files changed, 1007 insertions(+), 608 deletions(-) delete mode 100644 apps/tradinggoose/app/workspace/[workspaceId]/records/components/orders/order-provider-refresh.tsx diff --git a/apps/tradinggoose/app/api/orders/[orderId]/provider-detail/route.ts b/apps/tradinggoose/app/api/orders/[orderId]/provider-detail/route.ts index 69ab3061a..bab378dc9 100644 --- a/apps/tradinggoose/app/api/orders/[orderId]/provider-detail/route.ts +++ b/apps/tradinggoose/app/api/orders/[orderId]/provider-detail/route.ts @@ -2,7 +2,10 @@ import { type NextRequest, NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { createLogger } from '@/lib/logs/console/logger' import { isTradingServiceError } from '@/lib/trading/errors' -import { getRecordedTradingOrderProviderDetail } from '@/lib/trading/order-detail' +import { + getRecordedTradingOrderProviderDetail, + type TradingProviderOrderDetailResponse, +} from '@/lib/trading/order-detail' import { generateRequestId } from '@/lib/utils' const logger = createLogger('OrderProviderDetailAPI') @@ -34,7 +37,7 @@ export async function POST( workspaceId, }) - return NextResponse.json({ data: providerDetail }) + return NextResponse.json({ data: providerDetail } satisfies TradingProviderOrderDetailResponse) } catch (error) { if (isTradingServiceError(error)) { return NextResponse.json({ error: error.message }, { status: error.status }) diff --git a/apps/tradinggoose/app/api/orders/[orderId]/route.test.ts b/apps/tradinggoose/app/api/orders/[orderId]/route.test.ts index 05867ad9d..2bbb018b4 100644 --- a/apps/tradinggoose/app/api/orders/[orderId]/route.test.ts +++ b/apps/tradinggoose/app/api/orders/[orderId]/route.test.ts @@ -90,7 +90,7 @@ const orderRow = { recordedAt: new Date('2026-04-23T00:00:00.000Z'), submissionSource: 'workflow', logId: 'log-1', - listingIdentity: { listing_type: 'stock', listing_id: 'AAPL' }, + listingIdentity: { base_id: '', listing_id: 'AAPL', listing_type: 'default', quote_id: '' }, request: { side: 'buy', quantity: 5, orderType: 'limit', timeInForce: 'day' }, response: { orderId: 'provider-order-1', submittedAt: '2026-04-23T00:00:00.000Z' }, normalizedOrder: { symbol: 'AAPL', status: 'filled', averageFillPrice: '184.25' }, diff --git a/apps/tradinggoose/app/api/orders/route.test.ts b/apps/tradinggoose/app/api/orders/route.test.ts index 9b13b2040..2841d333c 100644 --- a/apps/tradinggoose/app/api/orders/route.test.ts +++ b/apps/tradinggoose/app/api/orders/route.test.ts @@ -113,7 +113,7 @@ const orderRow = { recordedAt: new Date('2026-04-23T00:00:00.000Z'), submissionSource: 'workflow', logId: 'log-1', - listingIdentity: { listing_type: 'default', listing_id: 'AAPL' }, + listingIdentity: { base_id: '', listing_id: 'AAPL', listing_type: 'default', quote_id: '' }, request: { side: 'buy', quantity: 1 }, response: { orderId: 'provider-order-1' }, normalizedOrder: { symbol: 'AAPL', status: 'filled' }, diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/records/components/log-details/log-details.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/records/components/log-details/log-details.tsx index 2a20c1b8f..ed9edd91c 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/records/components/log-details/log-details.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/records/components/log-details/log-details.tsx @@ -23,6 +23,7 @@ interface LogDetailsProps { isOpen: boolean isLoading?: boolean stateContent?: ReactNode + headerControls?: ReactNode onClose: () => void onNavigateNext?: () => void onNavigatePrev?: () => void @@ -51,6 +52,7 @@ export function LogDetails({ isOpen, isLoading = false, stateContent, + headerControls, onClose, onNavigateNext, onNavigatePrev, @@ -143,6 +145,7 @@ export function LogDetails({

Log Details

+ {headerControls} diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/records/components/orders/index.ts b/apps/tradinggoose/app/workspace/[workspaceId]/records/components/orders/index.ts index 59b922fdb..ccac41794 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/records/components/orders/index.ts +++ b/apps/tradinggoose/app/workspace/[workspaceId]/records/components/orders/index.ts @@ -1,7 +1,6 @@ export { OrderDetails } from './order-details' export { OrderEmptyState } from './order-empty-state' export { OrderFilterMenu, OrderFilters } from './order-filters' -export { OrderProviderRefresh } from './order-provider-refresh' export { OrderRowActions } from './order-row-actions' export { OrderStatusBadge } from './order-status-badge' export { OrdersTable } from './orders-table' diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/records/components/orders/order-details.test.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/records/components/orders/order-details.test.tsx index 63a46e50f..1d95eab3f 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/records/components/orders/order-details.test.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/records/components/orders/order-details.test.tsx @@ -8,22 +8,42 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { RecordsOrder } from '@/hooks/queries/records-orders' import { OrderDetails } from './order-details' +const mockUseResolvedListings = vi.fn() +const mockUseProviderOrderDetail = vi.fn() +const mockProviderRefetch = vi.fn() + vi.mock('@/components/ui/scroll-area', () => ({ ScrollArea: ({ children }: any) =>
{children}
, })) vi.mock('@/app/workspace/[workspaceId]/records/components/log-details/log-details', () => ({ - LogDetails: ({ stateContent }: any) =>
log details {stateContent}
, + LogDetails: ({ headerControls, log }: any) => ( +
+ log details {log?.id} + {headerControls} +
+ ), +})) + +vi.mock('@/hooks/queries/listing-resolution', () => ({ + useResolvedListings: (...args: unknown[]) => mockUseResolvedListings(...args), })) -vi.mock('./order-provider-refresh', () => ({ - OrderProviderRefresh: () =>
provider refresh
, +vi.mock('@/hooks/queries/records-orders', () => ({ + useProviderOrderDetail: (...args: unknown[]) => mockUseProviderOrderDetail(...args), })) const reactActEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } +const listingIdentity = { + base_id: '', + listing_id: 'TG_LSTG_AAPL', + listing_type: 'default' as const, + quote_id: '', +} + const order: RecordsOrder = { averageFillPrice: '184.25', clientOrderId: 'client-order-1', @@ -43,7 +63,7 @@ const order: RecordsOrder = { workflowName: 'Workflow', }, listing: { listingType: 'stock', name: 'Apple Inc.', symbol: 'AAPL' }, - listingIdentity: { listing_id: 'AAPL', listing_type: 'stock' }, + listingIdentity, message: 'Filled successfully', normalizedOrder: { status: 'filled' }, notional: null, @@ -72,6 +92,13 @@ describe('OrderDetails', () => { beforeEach(() => { reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true + mockUseResolvedListings.mockReturnValue({ data: {} }) + mockUseProviderOrderDetail.mockReturnValue({ + data: null, + error: null, + isFetching: false, + refetch: mockProviderRefetch, + }) container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) @@ -80,10 +107,13 @@ describe('OrderDetails', () => { afterEach(() => { act(() => root.unmount()) container.remove() + mockUseResolvedListings.mockReset() + mockUseProviderOrderDetail.mockReset() + mockProviderRefetch.mockReset() reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = false }) - it('renders normalized order data and switches detail modes through the header controls', async () => { + it('renders normalized order data and refreshes provider details from the header control', async () => { const onModeChange = vi.fn() await act(async () => { @@ -105,33 +135,55 @@ describe('OrderDetails', () => { /> ) }) + await act(async () => { + await Promise.resolve() + }) expect(container.textContent).toContain('AAPL') - expect(container.textContent).toContain('App order id') - expect(container.textContent).toContain('order-1') + expect(container.textContent).toContain('Apple Inc.') + expect(container.textContent).toContain('STOCK') + expect(container.textContent).not.toContain('DEFAULT') + expect(container.textContent).not.toContain('Resolving listing') + expect(container.textContent).not.toContain('App order id') + expect(container.textContent).not.toContain('Client order id') + expect(container.textContent).not.toContain('client-order-1') expect(container.textContent).toContain('Order type') expect(container.textContent).toContain('Limit') expect(container.textContent).toContain('Time in force') expect(container.textContent).toContain('DAY') - expect(container.textContent).toContain('Log connected') + expect(container.textContent).toContain('Execution') + expect(container.textContent).toContain('Execution price') + expect(container.textContent).toContain('Provider order id') + expect(container.textContent).toContain('Timeline') + expect(container.textContent).toContain('Workflow') + expect(container.textContent).not.toContain('listingIdentity') + expect(container.textContent).not.toContain('normalizedOrder') + expect(mockUseResolvedListings).toHaveBeenCalledWith({ + listings: [listingIdentity], + enabled: true, + }) + expect(mockUseProviderOrderDetail).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + orderId: 'order-1', + enabled: false, + }) const providerButton = Array.from(container.querySelectorAll('button')).find( - (node) => node.textContent === 'Provider' + (node) => node.textContent === 'Refresh provider order detail' ) if (!(providerButton instanceof HTMLButtonElement)) { - throw new Error('Expected provider mode button to render') + throw new Error('Expected provider refresh button to render') } await act(async () => { providerButton.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) - expect(onModeChange).toHaveBeenCalledWith('provider') + expect(onModeChange).toHaveBeenCalledWith('order') + expect(mockProviderRefetch).toHaveBeenCalled() }) - it('keeps order panel controls visible when log mode cannot load a log', async () => { - const onModeChange = vi.fn() - + it('keeps order data access visible when log mode loads a linked log', async () => { await act(async () => { root.render( { detail={null} detailsLoading={false} detailsError={null} - linkedLog={null} + linkedLog={{ id: 'log-1' } as any} linkedLogLoading={false} - linkedLogError='Workflow log unavailable' + linkedLogError={null} mode='log' - onModeChange={onModeChange} + onModeChange={vi.fn()} onClose={vi.fn()} onRetryDetails={vi.fn()} onRetryLog={vi.fn()} @@ -152,9 +204,88 @@ describe('OrderDetails', () => { ) }) - expect(container.textContent).toContain('Workflow log unavailable') + expect(container.textContent).toContain('log details log-1') expect(container.textContent).toContain('Order data') - expect(container.textContent).toContain('Provider') - expect(container.textContent).toContain('Close') + }) + + it('renders provider refresh differences inside the order data card', async () => { + mockUseProviderOrderDetail.mockReturnValue({ + data: { + data: { + orderDetail: { + averageFillPrice: '185.10', + filledAt: '2026-04-23T00:03:00.000Z', + filledQuantity: '4', + remainingQuantity: '1', + status: 'partially_filled', + updatedAt: '2026-04-23T00:03:00.000Z', + }, + }, + }, + error: null, + isFetching: false, + refetch: mockProviderRefetch, + }) + + await act(async () => { + root.render( + + ) + }) + + expect(container.textContent).toContain('latest') + expect(container.textContent).toContain('Partially Filled') + expect(container.textContent).toContain('$185.10') + expect(container.textContent).not.toContain('raw') + }) + + it('does not synthesize execution price or fee for an unfilled order', async () => { + await act(async () => { + root.render( + + ) + }) + + expect(container.textContent).toMatch(/Execution price\s*—/) + expect(container.textContent).toMatch(/Fee\s*—/) + expect(container.textContent).not.toContain('$184.25') }) }) diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/records/components/orders/order-details.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/records/components/orders/order-details.tsx index 1fdbefd57..299695d93 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/records/components/orders/order-details.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/records/components/orders/order-details.tsx @@ -1,21 +1,31 @@ 'use client' import type React from 'react' -import { AlertCircle, ChevronLeft, ChevronRight, Loader2, X } from 'lucide-react' +import { useMemo } from 'react' +import { AlertCircle, ChevronDown, ChevronUp, Loader2, RefreshCw, X } from 'lucide-react' +import { MarketListingRow } from '@/components/listing-selector/listing/row' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' +import { CopyButton } from '@/components/ui/copy-button' import { ScrollArea } from '@/components/ui/scroll-area' +import { getListingIdentityKey, toListingValueObject } from '@/lib/listing/identity' import { LogDetails } from '@/app/workspace/[workspaceId]/records/components/log-details/log-details' +import { useResolvedListings } from '@/hooks/queries/listing-resolution' +import { + type ProviderOrderDetailResponse, + useProviderOrderDetail, +} from '@/hooks/queries/records-orders' +import { getTradingProviderOAuthServiceIds } from '@/providers/trading/providers' +import type { TradingProviderId } from '@/providers/trading/types' import type { WorkflowLog } from '@/stores/logs/filters/types' import { formatDateTime, formatMoney, formatNumber, - getExecutionPrice, + getOrderListingFallback, titleCase, uppercase, } from './order-formatters' -import { OrderProviderRefresh } from './order-provider-refresh' import { OrderStatusBadge } from './order-status-badge' import type { RecordsOrder, RecordsOrderDetailMode } from './types' @@ -39,21 +49,110 @@ type OrderDetailsProps = { onRetryLog: () => void } -const DetailRow = ({ label, value }: { label: string; value: React.ReactNode }) => ( -
-
{label}
-
{value ?? '—'}
+const hasValue = (value: unknown) => value !== null && value !== undefined && value !== '' + +const DetailSection = ({ title, children }: { title: string; children: React.ReactNode }) => ( +
+
+

{title}

+
+
{children}
+
+) + +const DetailRow = ({ + label, + value, + copyValue, +}: { + label: string + value: React.ReactNode + copyValue?: string | null +}) => ( +
+
+ {label} +
+
+ {hasValue(value) ? value : '—'} +
+ {copyValue ? : null}
) -function JsonBlock({ title, value }: { title: string; value: unknown }) { +const CopyableCode = ({ value }: { value: string | null | undefined }) => + value ? {value} : '—' + +const changedProviderRows = ( + order: RecordsOrder, + providerDetail: ProviderOrderDetailResponse['data']['orderDetail'] | null +) => { + if (!providerDetail) return [] + const savedExecutionPrice = order.fillPrice ?? order.averageFillPrice + const rows = [ + ['Status', titleCase(order.status), titleCase(providerDetail.status)], + [ + 'Filled quantity', + formatNumber(order.filledQuantity), + formatNumber(providerDetail.filledQuantity), + ], + [ + 'Remaining quantity', + formatNumber(order.remainingQuantity), + formatNumber(providerDetail.remainingQuantity), + ], + [ + 'Execution price', + formatMoney(savedExecutionPrice), + formatMoney(providerDetail.averageFillPrice), + ], + ['Updated at', formatDateTime(order.updatedAt), formatDateTime(providerDetail.updatedAt)], + ['Filled at', formatDateTime(order.filledAt), formatDateTime(providerDetail.filledAt)], + ] as const + + return rows.filter(([, saved, latest]) => latest !== '—' && latest !== saved) +} + +function useResolvedOrderListing(listingIdentityValue: unknown) { + const listingIdentity = useMemo( + () => toListingValueObject(listingIdentityValue), + [listingIdentityValue] + ) + const listings = useMemo(() => (listingIdentity ? [listingIdentity] : []), [listingIdentity]) + const resolvedListingsQuery = useResolvedListings({ + listings, + enabled: Boolean(listingIdentity), + }) + const listing = listingIdentity + ? (resolvedListingsQuery.data?.[getListingIdentityKey(listingIdentity)] ?? null) + : null + + return { listing, listingIdentity } +} + +function ResolvedOrderListing({ + order, + compact = false, + showAssetClass = false, + className, +}: { + order: RecordsOrder + compact?: boolean + showAssetClass?: boolean + className?: string +}) { + const { listing, listingIdentity } = useResolvedOrderListing(order.listingIdentity) + const displayListing = listing ?? getOrderListingFallback(order) + return ( -
- {title} -
-        {JSON.stringify(value ?? null, null, 2)}
-      
-
+ ) } @@ -63,19 +162,34 @@ function OrderData({ loading, error, onRetry, + providerDetail, + providerDetailError, }: { order: RecordsOrder detail: RecordsOrder | null loading: boolean error: string | null onRetry: () => void + providerDetail: ProviderOrderDetailResponse | undefined + providerDetailError: unknown }) { const active = detail ?? order - const executionPrice = getExecutionPrice(active) + const executionPrice = active.fillPrice ?? active.averageFillPrice + const latestProviderDetail = providerDetail?.data.orderDetail ?? null + const providerRows = changedProviderRows(active, latestProviderDetail) + const showWorkflow = + Boolean(active.logId) || + Boolean(active.linkedLog?.executionId) || + Boolean(active.linkedLog?.workflowName) + const optionalTimelineRows = [ + ['Filled at', active.filledAt], + ['Canceled at', active.canceledAt], + ['Expired at', active.expiredAt], + ] as const return ( - -
+ +
{loading ? (
@@ -90,59 +204,123 @@ function OrderData({
) : null} -
-
-

- {active.listing.symbol ?? 'Unknown listing'} -

- - {titleCase(active.submissionSource)} - - {active.logId ? 'Log connected' : 'No log connected'} - +
+
+ +
+ + {titleCase(active.submissionSource)} +
-

- {active.message ?? 'Saved order submission record'} -

+ {active.message ? ( +

{active.message}

+ ) : null}
-
- {active.id}} /> - - - - - - + - - - - - + {hasValue(active.remainingQuantity) ? ( + + ) : null} + {hasValue(active.notional) ? ( + + ) : null} + {hasValue(active.submittedPrice) ? ( + + ) : null} + - - - - -
+ -
- - - -
+ + + + {hasValue(active.providerOrderId) ? ( + } + copyValue={active.providerOrderId} + /> + ) : null} + {providerDetailError ? ( + + {providerDetailError instanceof Error + ? providerDetailError.message + : 'Provider detail check failed.'} + + } + /> + ) : latestProviderDetail ? ( + providerRows.length > 0 ? ( + providerRows.map(([label, saved, latest]) => ( + + {saved} + latest + {latest} + + } + /> + )) + ) : ( + + ) + ) : null} + -
- - - - -
+ + + {hasValue(active.submittedAt) ? ( + + ) : null} + {hasValue(active.updatedAt) ? ( + + ) : null} + {optionalTimelineRows.map(([label, value]) => + value ? : null + )} + + + {showWorkflow ? ( + + {hasValue(active.linkedLog?.workflowName) ? ( + + ) : null} + {hasValue(active.logId) ? ( + } + copyValue={active.logId} + /> + ) : null} + {hasValue(active.linkedLog?.executionId) ? ( + } + copyValue={active.linkedLog?.executionId} + /> + ) : null} + {hasValue(active.linkedLog?.level) ? ( + + ) : null} + {hasValue(active.linkedLog?.startedAt) ? ( + + ) : null} + {hasValue(active.linkedLog?.endedAt) ? ( + + ) : null} + + ) : null}
) @@ -167,11 +345,24 @@ export function OrderDetails({ onRetryDetails, onRetryLog, }: OrderDetailsProps) { + const canCheckProvider = + getTradingProviderOAuthServiceIds(order.provider as TradingProviderId).length > 0 + const providerDetailQuery = useProviderOrderDetail({ + workspaceId, + orderId: order.id, + enabled: false, + }) + if (mode === 'log' && linkedLog) { return ( onModeChange('order')}> + Order data + + } onClose={onClose} onNavigateNext={onNavigateNext} onNavigatePrev={onNavigatePrev} @@ -182,55 +373,54 @@ export function OrderDetails({ } return ( -
- - {mode === 'log' ? ( -
- {!order.logId ? ( - 'No log is connected to this order.' - ) : linkedLogLoading ? ( - - - Loading workflow log... - - ) : ( -
- -

{linkedLogError ?? 'Workflow log unavailable'}

- +
+
+ void providerDetailQuery.refetch()} + onClose={onClose} + onNavigateNext={onNavigateNext} + onNavigatePrev={onNavigatePrev} + hasNext={hasNext} + hasPrev={hasPrev} + /> +
+ {mode === 'log' ? ( +
+ {!order.logId ? ( + 'No log is connected to this order.' + ) : linkedLogLoading ? ( + + + Loading workflow log... + + ) : ( +
+ +

{linkedLogError ?? 'Workflow log unavailable'}

+ +
+ )}
- )} -
- ) : mode === 'provider' ? ( - -
- -
-
- ) : ( - - )} + )} +
+
) } @@ -238,7 +428,10 @@ export function OrderDetails({ function OrderPanelHeader({ order, mode, + canCheckProvider, + isCheckingProvider, onModeChange, + onCheckProvider, onClose, onNavigateNext, onNavigatePrev, @@ -247,7 +440,10 @@ function OrderPanelHeader({ }: { order: RecordsOrder mode: RecordsOrderDetailMode + canCheckProvider: boolean + isCheckingProvider: boolean onModeChange: (mode: RecordsOrderDetailMode) => void + onCheckProvider: () => void onClose: () => void onNavigateNext?: () => void onNavigatePrev?: () => void @@ -255,12 +451,9 @@ function OrderPanelHeader({ hasPrev: boolean }) { return ( -
+
-
{order.listing.symbol ?? order.id}
-
- {order.providerOrderId ?? order.id} -
+

Order Details

- diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/records/components/orders/order-formatters.ts b/apps/tradinggoose/app/workspace/[workspaceId]/records/components/orders/order-formatters.ts index 54f493b4d..217ee3c20 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/records/components/orders/order-formatters.ts +++ b/apps/tradinggoose/app/workspace/[workspaceId]/records/components/orders/order-formatters.ts @@ -1,3 +1,4 @@ +import type { ListingOption } from '@/lib/listing/identity' import type { RecordsOrder } from '@/hooks/queries/records-orders' export const titleCase = (value: string | null | undefined) => @@ -82,3 +83,19 @@ export function getExecutionPrice(order: RecordsOrder) { export function orderIdentifier(order: RecordsOrder) { return order.providerOrderId ?? order.id } + +export function getOrderListingFallback(order: RecordsOrder): ListingOption | null { + const symbol = order.listing.symbol?.trim() + const name = order.listing.name?.trim() + if (!symbol && !name) return null + + return { + listing_id: symbol ?? name ?? '', + base_id: '', + quote_id: '', + listing_type: 'default', + base: symbol ?? name ?? 'Listing', + name: name ?? symbol ?? null, + assetClass: order.listing.listingType, + } +} diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/records/components/orders/order-provider-refresh.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/records/components/orders/order-provider-refresh.tsx deleted file mode 100644 index a8475dfce..000000000 --- a/apps/tradinggoose/app/workspace/[workspaceId]/records/components/orders/order-provider-refresh.tsx +++ /dev/null @@ -1,67 +0,0 @@ -'use client' - -import { Loader2, RefreshCw } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { useProviderOrderDetail } from '@/hooks/queries/records-orders' -import { getTradingProviderOAuthServiceIds } from '@/providers/trading/providers' -import type { TradingProviderId } from '@/providers/trading/types' -import type { RecordsOrder } from './types' - -export function OrderProviderRefresh({ - workspaceId, - order, - active, -}: { - workspaceId: string - order: RecordsOrder - active: boolean -}) { - const providerId = order.provider as TradingProviderId - const oauthServiceIds = getTradingProviderOAuthServiceIds(providerId) - - const providerDetailQuery = useProviderOrderDetail({ - workspaceId, - orderId: order.id, - enabled: false, - }) - - if (oauthServiceIds.length === 0) { - return ( -
- Provider refresh is unavailable for this provider. -
- ) - } - - return ( -
- - - {providerDetailQuery.error ? ( -

- {providerDetailQuery.error instanceof Error - ? providerDetailQuery.error.message - : 'Provider detail refresh failed.'} -

- ) : null} - - {providerDetailQuery.data ? ( -
-          {JSON.stringify(providerDetailQuery.data, null, 2)}
-        
- ) : null} -
- ) -} diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/records/components/orders/order-row-actions.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/records/components/orders/order-row-actions.tsx index f93a617fd..2f24012a7 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/records/components/orders/order-row-actions.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/records/components/orders/order-row-actions.tsx @@ -2,7 +2,7 @@ import type React from 'react' import { useState } from 'react' -import { Check, Copy, ExternalLink, FileSearch, PanelRightOpen } from 'lucide-react' +import { Check, Copy, ExternalLink } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import type { RecordsOrder } from '@/hooks/queries/records-orders' @@ -10,17 +10,10 @@ import { orderIdentifier } from './order-formatters' interface OrderRowActionsProps { order: RecordsOrder - onOpenOrder: (order: RecordsOrder) => void - onOpenLog: (order: RecordsOrder) => void - onOpenProvider: (order: RecordsOrder) => void + providerOrderDetailUrl: string | null } -export function OrderRowActions({ - order, - onOpenOrder, - onOpenLog, - onOpenProvider, -}: OrderRowActionsProps) { +export function OrderRowActions({ order, providerOrderDetailUrl }: OrderRowActionsProps) { const [copied, setCopied] = useState(false) const stop = (event: React.MouseEvent) => event.stopPropagation() @@ -34,43 +27,6 @@ export function OrderRowActions({ return (
- - - - - Order data - - - - - - - {order.logId ? 'Log detail' : 'No linked log'} - - - - Refresh provider detail - + {providerOrderDetailUrl ? ( + + + + + Open provider order detail + + ) : null}
) } diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/records/components/orders/orders-table.test.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/records/components/orders/orders-table.test.tsx index 061a8ac79..42a6696c7 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/records/components/orders/orders-table.test.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/records/components/orders/orders-table.test.tsx @@ -8,16 +8,29 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { RecordsOrder } from '@/hooks/queries/records-orders' import { OrdersTable } from './orders-table' +const mockUseResolvedListings = vi.fn() + vi.mock('@/components/ui/tooltip', () => ({ Tooltip: ({ children }: any) => <>{children}, TooltipContent: ({ children }: any) => <>{children}, TooltipTrigger: ({ children }: any) => <>{children}, })) +vi.mock('@/hooks/queries/listing-resolution', () => ({ + useResolvedListings: (...args: unknown[]) => mockUseResolvedListings(...args), +})) + const reactActEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } +const listingIdentity = { + base_id: '', + listing_id: 'TG_LSTG_AAPL', + listing_type: 'default' as const, + quote_id: '', +} + const order: RecordsOrder = { averageFillPrice: '184.25', clientOrderId: 'client-order-1', @@ -37,7 +50,7 @@ const order: RecordsOrder = { workflowName: 'Workflow', }, listing: { listingType: 'stock', name: 'Apple Inc.', symbol: 'AAPL' }, - listingIdentity: { listing_id: 'AAPL', listing_type: 'stock' }, + listingIdentity, message: 'Filled successfully', normalizedOrder: { status: 'filled' }, notional: null, @@ -66,6 +79,7 @@ describe('OrdersTable', () => { beforeEach(() => { reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true + mockUseResolvedListings.mockReturnValue({ data: {} }) container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) @@ -74,6 +88,7 @@ describe('OrdersTable', () => { afterEach(() => { act(() => root.unmount()) container.remove() + mockUseResolvedListings.mockReset() reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = false }) @@ -85,7 +100,6 @@ describe('OrdersTable', () => { root.render( { sortOrder='desc' onSortChange={onSortChange} onOrderClick={onOrderClick} - onOpenOrder={vi.fn()} - onOpenLog={vi.fn()} - onOpenProvider={vi.fn()} loaderRef={createRef()} scrollContainerRef={createRef()} selectedRowRef={createRef()} /> ) }) + await act(async () => { + await Promise.resolve() + }) expect(container.textContent).toContain('AAPL') + expect(container.textContent).toContain('Apple Inc.') + expect(container.textContent).not.toContain('Resolving listing') + expect(container.textContent).not.toContain('STOCK') + expect(container.textContent).not.toContain('DEFAULT') + expect(container.textContent).not.toContain('TG_LSTG_AAPL') + expect(container.textContent).not.toContain('Order IDs') + expect(container.textContent).not.toContain('provider-order-1') + expect(container.textContent).not.toContain('Linked') + expect(container.textContent).not.toContain('Recorded ') + expect(container.textContent).toContain('Created at') + expect(container.textContent).toContain('Updated at') expect(container.textContent).toContain('Workflow') expect(container.textContent).toContain('Limit') expect(container.textContent).toContain('DAY') + expect(container.querySelector('img[alt="US flag"]')).toBeNull() expect(container.querySelector('.selected-row')).toBeTruthy() + expect(mockUseResolvedListings).toHaveBeenCalledWith({ + listings: [listingIdentity], + enabled: true, + }) - const row = Array.from(container.querySelectorAll('tr')).find((node) => - node.textContent?.includes('provider-order-1') - ) + const row = container.querySelector('tbody tr') if (!(row instanceof HTMLTableRowElement)) { throw new Error('Expected order row to render') } @@ -123,5 +151,23 @@ describe('OrdersTable', () => { }) expect(onOrderClick).toHaveBeenCalledWith(order) + + onOrderClick.mockClear() + const providerDetailLink = Array.from(container.querySelectorAll('a')).find( + (node) => node.textContent === 'Open provider order detail' + ) + if (!(providerDetailLink instanceof HTMLAnchorElement)) { + throw new Error('Expected provider detail link to render') + } + + expect(providerDetailLink.href).toBe( + 'https://app.alpaca.markets/dashboard/order/provider-order-1' + ) + + await act(async () => { + providerDetailLink.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(onOrderClick).not.toHaveBeenCalled() }) }) diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/records/components/orders/orders-table.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/records/components/orders/orders-table.tsx index fb9d986b7..b31a90be9 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/records/components/orders/orders-table.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/records/components/orders/orders-table.tsx @@ -1,26 +1,28 @@ 'use client' import type React from 'react' -import type { RefObject } from 'react' +import { type RefObject, useMemo } from 'react' import { AlertCircle, ArrowDown, ArrowUp, Info, Loader2 } from 'lucide-react' +import { MarketListingRow } from '@/components/listing-selector/listing/row' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' +import { TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table' + getListingIdentityKey, + type ListingIdentity, + toListingValueObject, +} from '@/lib/listing/identity' import type { OrdersFilterState } from '@/lib/records/order-filters' import { cn } from '@/lib/utils' +import { useResolvedListings } from '@/hooks/queries/listing-resolution' import type { RecordsOrder } from '@/hooks/queries/records-orders' +import { getTradingProviderDefinition } from '@/providers/trading/providers' import { formatCompactDateTime, formatMoney, formatNumber, getExecutionPrice, + getOrderListingFallback, titleCase, uppercase, } from './order-formatters' @@ -29,7 +31,6 @@ import { OrderStatusBadge } from './order-status-badge' type OrdersTableProps = { orders: RecordsOrder[] - total: number selectedOrderId: string | null loading: boolean error: string | null @@ -39,29 +40,33 @@ type OrdersTableProps = { sortOrder: OrdersFilterState['orderSortOrder'] onSortChange: (sortBy: OrdersFilterState['orderSortBy']) => void onOrderClick: (order: RecordsOrder) => void - onOpenOrder: (order: RecordsOrder) => void - onOpenLog: (order: RecordsOrder) => void - onOpenProvider: (order: RecordsOrder) => void loaderRef: RefObject scrollContainerRef: RefObject selectedRowRef: RefObject } const columns = [ - 'min-w-[150px]', - 'min-w-[110px]', - 'min-w-[170px]', - 'min-w-[110px]', - 'min-w-[115px]', - 'min-w-[130px]', - 'min-w-[115px]', - 'min-w-[120px]', - 'min-w-[125px]', - 'min-w-[120px]', - 'min-w-[100px]', - 'min-w-[130px]', + 'w-[240px]', + 'w-[100px]', + 'w-[90px]', + 'w-[105px]', + 'w-[125px]', + 'w-[110px]', + 'w-[105px]', + 'w-[120px]', + 'w-[120px]', + 'w-[120px]', + 'w-[110px]', ] +const orderTableMinWidth = 'min-w-[1345px]' +const tableHeadClassName = 'px-4 pt-2 pb-3 text-center align-middle font-medium' +const tableCellClassName = 'px-4 py-3 text-center align-middle' + +function HeadLabel({ children }: { children: React.ReactNode }) { + return {children} +} + function SortHead({ field, current, @@ -80,14 +85,14 @@ function SortHead({ const active = field === current return (
- + {listingDropdown && portalTarget && dropdownPosition + ? createPortal(listingDropdown, portalTarget) + : listingDropdown} {blockId ? ( = {} - const filtersPayload: Record = { - limit: 50, - } + const filtersPayload: Record = {} const parsedQuery: ParsedMarketQuery = trimmed ? parseCategorizedSearchQuery(trimmed) : {} + if ( + parsedQuery.assetClass && + providerConfig.assetClasses.length && + !providerConfig.assetClasses.includes(parsedQuery.assetClass) + ) { + return { + queryParams, + requestKey: JSON.stringify(queryParams), + } + } + const resolvedAssetClasses = parsedQuery.assetClass ? [parsedQuery.assetClass] : providerConfig.assetClasses.length @@ -67,23 +74,9 @@ export function buildMarketSearchRequest(args: { if (parsedQuery.region) { filtersPayload.region = [parsedQuery.region] } - if (Object.keys(filtersPayload).length > 0) { - queryParams.filters = JSON.stringify(filtersPayload) + if (Object.keys(queryParams).length > 0 || Object.keys(filtersPayload).length > 0) { + queryParams.filters = JSON.stringify({ limit: 50, ...filtersPayload }) } - const requestKey = JSON.stringify({ - trimmed, - rawQuery, - providerId, - providerType, - assetClasses: resolvedAssetClasses, - marketCodes: resolvedMarketCodes, - listingQuoteCodes: providerConfig.listingQuoteCodes, - cryptoQuoteCodes: providerConfig.cryptoQuoteCodes, - currencyQuoteCodes: providerConfig.currencyQuoteCodes, - parsedQuery, - filters: filtersPayload, - }) - - return { queryParams, requestKey } + return { queryParams, requestKey: JSON.stringify(queryParams) } } diff --git a/apps/tradinggoose/components/listing-selector/selector/use-listing-search.test.tsx b/apps/tradinggoose/components/listing-selector/selector/use-listing-search.test.tsx index f68a00cd5..9364aaace 100644 --- a/apps/tradinggoose/components/listing-selector/selector/use-listing-search.test.tsx +++ b/apps/tradinggoose/components/listing-selector/selector/use-listing-search.test.tsx @@ -44,21 +44,9 @@ describe('useMarketListingSearch', () => { vi.useRealTimers() }) - it('searches immediately on blank open and stores the returned results', async () => { + it('does not search a blank open selector without query or provider criteria', async () => { const updateInstance = vi.fn() - fetchListingsMock.mockResolvedValue([ - { - listing_id: 'AAPL', - base_id: '', - quote_id: '', - listing_type: 'default', - base: 'AAPL', - quote: 'USD', - name: 'Apple Inc.', - }, - ]) - await act(async () => { root.render( { providerType='market' instanceId='test-selector' updateInstance={updateInstance} - isVariableInput={() => false} /> ) await Promise.resolve() }) - expect(fetchListingsMock).toHaveBeenCalledTimes(1) - expect(fetchListingsMock).toHaveBeenCalledWith( - expect.not.objectContaining({ - search_query: expect.anything(), - }), - expect.any(AbortSignal) - ) + expect(fetchListingsMock).not.toHaveBeenCalled() + expect(updateInstance).toHaveBeenCalledWith('test-selector', { - isLoading: true, + results: [], + isLoading: false, error: undefined, }) + }) + + it('searches a blank open selector with combined market and trading provider criteria', async () => { + const updateInstance = vi.fn() + + fetchListingsMock.mockResolvedValue([]) + + await act(async () => { + root.render( + + ) + await Promise.resolve() + }) + + expect(fetchListingsMock).toHaveBeenCalledTimes(1) + const queryParams = fetchListingsMock.mock.calls[0][0] as Record + expect(queryParams.search_query).toBeUndefined() + expect(queryParams.crypto_quote_code).toBe('[BTC,USD]') + expect(JSON.parse(queryParams.filters)).toEqual( + expect.objectContaining({ + limit: 50, + asset_class: ['stock', 'crypto'], + }) + ) + }) + + it('does not let explicit asset prefixes bypass combined provider criteria', async () => { + const updateInstance = vi.fn() await act(async () => { + root.render( + + ) + vi.advanceTimersByTime(400) await Promise.resolve() }) + expect(fetchListingsMock).not.toHaveBeenCalled() expect(updateInstance).toHaveBeenCalledWith('test-selector', { - results: [ - { - listing_id: 'AAPL', - base_id: '', - quote_id: '', - listing_type: 'default', - base: 'AAPL', - quote: 'USD', - name: 'Apple Inc.', - }, - ], + results: [], isLoading: false, error: undefined, }) @@ -129,19 +151,12 @@ describe('useMarketListingSearch', () => { providerType='market' instanceId='test-selector' updateInstance={updateInstance} - isVariableInput={() => false} /> ) await Promise.resolve() }) - expect(fetchListingsMock).toHaveBeenCalledTimes(1) - expect(fetchListingsMock).toHaveBeenLastCalledWith( - expect.not.objectContaining({ - search_query: expect.anything(), - }), - expect.any(AbortSignal) - ) + expect(fetchListingsMock).not.toHaveBeenCalled() fetchListingsMock.mockClear() updateInstance.mockClear() @@ -154,7 +169,6 @@ describe('useMarketListingSearch', () => { providerType='market' instanceId='test-selector' updateInstance={updateInstance} - isVariableInput={() => false} /> ) }) @@ -174,6 +188,7 @@ describe('useMarketListingSearch', () => { expect(fetchListingsMock).toHaveBeenCalledTimes(1) expect(fetchListingsMock).toHaveBeenCalledWith( expect.objectContaining({ + filters: JSON.stringify({ limit: 50 }), search_query: 'AAPL', }), expect.any(AbortSignal) @@ -216,7 +231,6 @@ describe('useMarketListingSearch', () => { providerType='market' instanceId='test-selector' updateInstance={updateInstance} - isVariableInput={() => false} /> ) await Promise.resolve() @@ -233,7 +247,6 @@ describe('useMarketListingSearch', () => { providerType='market' instanceId='test-selector' updateInstance={updateInstance} - isVariableInput={() => false} /> ) }) @@ -254,7 +267,6 @@ describe('useMarketListingSearch', () => { providerType='market' instanceId='test-selector' updateInstance={updateInstance} - isVariableInput={() => false} /> ) }) @@ -265,4 +277,58 @@ describe('useMarketListingSearch', () => { error: undefined, }) }) + + it('filters scoped candidate listings without calling market search', async () => { + const updateInstance = vi.fn() + + await act(async () => { + root.render( + + ) + await Promise.resolve() + }) + + expect(fetchListingsMock).not.toHaveBeenCalled() + expect(updateInstance).toHaveBeenCalledWith('test-selector', { + results: [ + { + listing_id: '', + base_id: 'BTC', + quote_id: 'USD', + listing_type: 'crypto', + base: 'BTC', + quote: 'USD', + name: 'BTC/USD', + }, + ], + isLoading: false, + error: undefined, + }) + }) }) diff --git a/apps/tradinggoose/components/listing-selector/selector/use-listing-search.ts b/apps/tradinggoose/components/listing-selector/selector/use-listing-search.ts index f59b792e0..0909a4109 100644 --- a/apps/tradinggoose/components/listing-selector/selector/use-listing-search.ts +++ b/apps/tradinggoose/components/listing-selector/selector/use-listing-search.ts @@ -1,12 +1,14 @@ -import { useEffect, useRef } from 'react' -import { useDebounce } from '@/hooks/use-debounce' +import { useEffect, useMemo, useRef } from 'react' import { fetchListings } from '@/components/listing-selector/fetchers' -import type { ListingSelectorInstance } from '@/stores/market/selector/store' import { buildMarketSearchRequest } from '@/components/listing-selector/selector/search-request' import { + combineProviderSearchConfigs, useMarketProviderSearchConfig, useTradingProviderSearchConfig, } from '@/components/listing-selector/selector/use-provider-config' +import type { ListingOption } from '@/lib/listing/identity' +import { useDebounce } from '@/hooks/use-debounce' +import type { ListingSelectorInstance } from '@/stores/market/selector/store' type UpdateInstance = (id: string, patch: Partial) => void @@ -15,9 +17,31 @@ type UseMarketListingSearchOptions = { query: string providerId?: string providerType?: 'market' | 'trading' + marketProviderId?: string + tradingProviderId?: string instanceId: string updateInstance: UpdateInstance - isVariableInput: (value: string) => boolean + candidateListings?: ListingOption[] + candidateListingsLoading?: boolean + candidateListingsError?: string +} + +const listingMatchesQuery = (listing: ListingOption, query: string): boolean => { + if (!query) return true + return [ + listing.base, + listing.quote, + listing.name, + listing.assetClass, + listing.listing_id, + listing.base_id, + listing.quote_id, + listing.listing_type, + ] + .filter(Boolean) + .join(' ') + .toLowerCase() + .includes(query) } export function useMarketListingSearch({ @@ -25,17 +49,31 @@ export function useMarketListingSearch({ query, providerId, providerType = 'market', + marketProviderId, + tradingProviderId, instanceId, updateInstance, - isVariableInput, + candidateListings, + candidateListingsLoading = false, + candidateListingsError, }: UseMarketListingSearchOptions) { const debouncedQuery = useDebounce(query, 400) const requestKeyRef = useRef('') const abortRef = useRef(null) - const marketProviderConfig = useMarketProviderSearchConfig(providerId) - const tradingProviderConfig = useTradingProviderSearchConfig(providerId) - const providerConfig = - providerType === 'trading' ? tradingProviderConfig : marketProviderConfig + const marketSearchProviderId = + marketProviderId ?? (providerType === 'market' ? providerId : undefined) + const tradingSearchProviderId = + tradingProviderId ?? (providerType === 'trading' ? providerId : undefined) + const marketProviderConfig = useMarketProviderSearchConfig(marketSearchProviderId) + const tradingProviderConfig = useTradingProviderSearchConfig(tradingSearchProviderId) + const providerConfig = useMemo( + () => + combineProviderSearchConfigs([ + ...(marketSearchProviderId ? [marketProviderConfig] : []), + ...(tradingSearchProviderId ? [tradingProviderConfig] : []), + ]), + [marketSearchProviderId, marketProviderConfig, tradingSearchProviderId, tradingProviderConfig] + ) const abortInFlightRequest = () => { requestKeyRef.current = '' @@ -61,12 +99,29 @@ export function useMarketListingSearch({ return } - if (isVariableInput(trimmedQuery)) { + if (trimmedQuery.startsWith('<')) { abortInFlightRequest() updateInstance(instanceId, { results: [], isLoading: false, error: undefined }) return } + if (candidateListings) { + abortInFlightRequest() + if (candidateListingsLoading) { + updateInstance(instanceId, { results: [], isLoading: true, error: undefined }) + return + } + + updateInstance(instanceId, { + results: candidateListings.filter((listing) => + listingMatchesQuery(listing, trimmedQuery.toLowerCase()) + ), + isLoading: false, + error: candidateListingsError, + }) + return + } + if (trimmedDebouncedQuery !== trimmedQuery) { abortInFlightRequest() updateInstance(instanceId, { @@ -78,11 +133,13 @@ export function useMarketListingSearch({ const { queryParams, requestKey } = buildMarketSearchRequest({ rawQuery: debouncedQuery, - providerId, - providerType, providerConfig, }) - requestKeyRef.current = requestKey + if (Object.keys(queryParams).length === 0) { + abortInFlightRequest() + updateInstance(instanceId, { results: [], isLoading: false, error: undefined }) + return + } abortInFlightRequest() requestKeyRef.current = requestKey @@ -121,9 +178,13 @@ export function useMarketListingSearch({ debouncedQuery, providerId, providerType, + marketProviderId, + tradingProviderId, providerConfig, instanceId, updateInstance, - isVariableInput, + candidateListings, + candidateListingsLoading, + candidateListingsError, ]) } diff --git a/apps/tradinggoose/components/listing-selector/selector/use-provider-config.ts b/apps/tradinggoose/components/listing-selector/selector/use-provider-config.ts index ba69e4ddd..056879fa1 100644 --- a/apps/tradinggoose/components/listing-selector/selector/use-provider-config.ts +++ b/apps/tradinggoose/components/listing-selector/selector/use-provider-config.ts @@ -11,90 +11,68 @@ export type ProviderSearchConfig = { currencyQuoteCodes: string[] } -export function useMarketProviderSearchConfig(providerId?: string): ProviderSearchConfig { - const providerConfig = useMemo( - () => (providerId ? getMarketProviderConfig(providerId) : null), - [providerId] - ) - - const assetClasses = useMemo(() => { - const values = providerConfig?.availability.assetClass ?? [] - return uniqueStrings(values) - }, [providerConfig]) - - const listingQuoteCodes = useMemo(() => { - const availability = providerConfig?.availability - return uniqueStrings(availability?.availableListingQuote ?? []) - }, [providerConfig]) +type ProviderSearchConfigSource = { + availability?: { + assetClass?: readonly string[] + availableListingQuote?: readonly string[] + availableCurrencyQuote?: readonly string[] + availableCryptoQuote?: readonly string[] + } + exchangeCodeToMarket?: Record +} - const currencyQuoteCodes = useMemo(() => { - const availability = providerConfig?.availability - return uniqueStrings(availability?.availableCurrencyQuote ?? []) - }, [providerConfig]) +const EMPTY_PROVIDER_SEARCH_CONFIG: ProviderSearchConfig = { + assetClasses: [], + marketCodes: [], + listingQuoteCodes: [], + cryptoQuoteCodes: [], + currencyQuoteCodes: [], +} - const cryptoQuoteCodes = useMemo(() => { - const availability = providerConfig?.availability - return uniqueStrings(availability?.availableCryptoQuote ?? []) - }, [providerConfig]) +const toProviderSearchConfig = ( + providerConfig: ProviderSearchConfigSource | null | undefined +): ProviderSearchConfig => { + const availability = providerConfig?.availability + return { + assetClasses: uniqueStrings(availability?.assetClass ?? []), + marketCodes: uniqueStrings(Object.values(providerConfig?.exchangeCodeToMarket ?? {})), + listingQuoteCodes: uniqueStrings(availability?.availableListingQuote ?? []), + cryptoQuoteCodes: uniqueStrings(availability?.availableCryptoQuote ?? []), + currencyQuoteCodes: uniqueStrings(availability?.availableCurrencyQuote ?? []), + } +} - const marketCodes = useMemo(() => { - const map = providerConfig?.exchangeCodeToMarket ?? {} - const codes = Object.values(map) - return uniqueStrings(codes) - }, [providerConfig]) +const combineValues = (left: string[], right: string[]): string[] => { + if (!left.length) return right + if (!right.length) return left + const rightValues = new Set(right) + return left.filter((value) => rightValues.has(value)) +} - return useMemo( - () => ({ - assetClasses, - marketCodes, - listingQuoteCodes, - cryptoQuoteCodes, - currencyQuoteCodes, +export const combineProviderSearchConfigs = ( + configs: ProviderSearchConfig[] +): ProviderSearchConfig => + configs.reduce( + (combined, config) => ({ + assetClasses: combineValues(combined.assetClasses, config.assetClasses), + marketCodes: combineValues(combined.marketCodes, config.marketCodes), + listingQuoteCodes: combineValues(combined.listingQuoteCodes, config.listingQuoteCodes), + cryptoQuoteCodes: combineValues(combined.cryptoQuoteCodes, config.cryptoQuoteCodes), + currencyQuoteCodes: combineValues(combined.currencyQuoteCodes, config.currencyQuoteCodes), }), - [assetClasses, marketCodes, listingQuoteCodes, cryptoQuoteCodes, currencyQuoteCodes] + EMPTY_PROVIDER_SEARCH_CONFIG ) -} -export function useTradingProviderSearchConfig(providerId?: string): ProviderSearchConfig { - const providerConfig = useMemo( - () => (providerId ? getTradingProviderConfig(providerId) : null), +export function useMarketProviderSearchConfig(providerId?: string): ProviderSearchConfig { + return useMemo( + () => toProviderSearchConfig(providerId ? getMarketProviderConfig(providerId) : null), [providerId] ) +} - const assetClasses = useMemo(() => { - const values = providerConfig?.availability.assetClass ?? [] - return uniqueStrings(values) - }, [providerConfig]) - - const listingQuoteCodes = useMemo(() => { - const availability = providerConfig?.availability - return uniqueStrings(availability?.availableListingQuote ?? []) - }, [providerConfig]) - - const currencyQuoteCodes = useMemo(() => { - const availability = providerConfig?.availability - return uniqueStrings(availability?.availableCurrencyQuote ?? []) - }, [providerConfig]) - - const cryptoQuoteCodes = useMemo(() => { - const availability = providerConfig?.availability - return uniqueStrings(availability?.availableCryptoQuote ?? []) - }, [providerConfig]) - - const marketCodes = useMemo(() => { - const map = providerConfig?.exchangeCodeToMarket ?? {} - const codes = Object.values(map) - return uniqueStrings(codes) - }, [providerConfig]) - +export function useTradingProviderSearchConfig(providerId?: string): ProviderSearchConfig { return useMemo( - () => ({ - assetClasses, - marketCodes, - listingQuoteCodes, - cryptoQuoteCodes, - currencyQuoteCodes, - }), - [assetClasses, marketCodes, listingQuoteCodes, cryptoQuoteCodes, currencyQuoteCodes] + () => toProviderSearchConfig(providerId ? getTradingProviderConfig(providerId) : null), + [providerId] ) } diff --git a/apps/tradinggoose/lib/listing/identity.ts b/apps/tradinggoose/lib/listing/identity.ts index e347dc6f4..86bc314fd 100644 --- a/apps/tradinggoose/lib/listing/identity.ts +++ b/apps/tradinggoose/lib/listing/identity.ts @@ -50,6 +50,29 @@ export type ListingOption = ListingResolved export type ListingValue = ListingIdentity | null | undefined export type ListingInputValue = ListingIdentity | ListingResolved | string | null | undefined +export function buildListingDisplayOption( + listing: ListingIdentity, + resolved?: ListingOption | null +): ListingOption { + const base = + resolved?.base?.trim() || + (listing.listing_type === 'default' ? listing.listing_id : listing.base_id) + const quote = + resolved?.quote?.trim() || (listing.listing_type === 'default' ? null : listing.quote_id) + + return { + ...listing, + ...resolved, + base, + quote, + name: + resolved?.name?.trim() || + (listing.listing_type === 'default' + ? listing.listing_id + : `${listing.base_id}/${listing.quote_id}`), + } +} + const readListingField = (record: Record, key: string): string | undefined => { const value = record[key] if (typeof value === 'string') { diff --git a/apps/tradinggoose/lib/watchlists/operations.ts b/apps/tradinggoose/lib/watchlists/operations.ts index 18a706399..12989a10a 100644 --- a/apps/tradinggoose/lib/watchlists/operations.ts +++ b/apps/tradinggoose/lib/watchlists/operations.ts @@ -757,6 +757,37 @@ export async function removeWatchlistItem( }) } +export async function removeListingFromWatchlist( + scope: WatchlistScope, + watchlistId: string, + listingInput: ListingIdentity +): Promise { + const listing = toListingValueObject(listingInput) + if (!listing) { + throw new WatchlistOperationError('Invalid listing payload', 400) + } + + return db.transaction(async (tx) => { + const row = await fetchWatchlistRow(tx, watchlistId, scope) + const { items } = await loadWatchlistRows(tx, row) + const target = items.find((item) => { + const identity = toListingValueObject(item.listing as ListingInputValue) + return identity ? areListingIdentitiesEqual(identity, listing) : false + }) + + if (!target) { + return mapRecordInTx(tx, row) + } + + await tx + .delete(watchlistItem) + .where(and(eq(watchlistItem.id, target.id), eq(watchlistItem.watchlistId, row.id))) + + const updated = await touchWatchlist(tx, row.id) + return mapRecordInTx(tx, updated) + }) +} + export async function removeWatchlistSection( scope: WatchlistScope, watchlistId: string, diff --git a/apps/tradinggoose/tools/params.ts b/apps/tradinggoose/tools/params.ts index 36061f6df..011cfe74f 100644 --- a/apps/tradinggoose/tools/params.ts +++ b/apps/tradinggoose/tools/params.ts @@ -1,8 +1,5 @@ +import { LISTING_IDENTITY_JSON_SCHEMA, LISTING_IDENTITY_VALUE_TYPE } from '@/lib/listing/identity' import { createLogger } from '@/lib/logs/console/logger' -import { - LISTING_IDENTITY_JSON_SCHEMA, - LISTING_IDENTITY_VALUE_TYPE, -} from '@/lib/listing/identity' import type { BlockConfig, SubBlockCondition as ComponentCondition, @@ -205,6 +202,7 @@ export function getToolParametersConfig( tooltip: subBlock.tooltip, password: subBlock.password, condition: resolveSubBlockCondition(subBlock.condition), + fetchOptionsCondition: resolveSubBlockCondition(subBlock.fetchOptionsCondition), title: subBlock.title, layout: subBlock.layout, value: subBlock.value, @@ -212,7 +210,7 @@ export function getToolParametersConfig( serviceId: subBlock.serviceId, requiredScopes: subBlock.requiredScopes, providerType: subBlock.providerType, - providerFieldId: subBlock.providerFieldId, + tradingProviderFieldId: subBlock.tradingProviderFieldId, enableSearch: subBlock.enableSearch, searchPlaceholder: subBlock.searchPlaceholder, mimeType: subBlock.mimeType, diff --git a/apps/tradinggoose/tools/registry.ts b/apps/tradinggoose/tools/registry.ts index faceba22a..c98130b3f 100644 --- a/apps/tradinggoose/tools/registry.ts +++ b/apps/tradinggoose/tools/registry.ts @@ -66,7 +66,6 @@ import { listMattersTool, } from '@/tools/google_vault' import { guardrailsValidateTool } from '@/tools/guardrails' -import { historicalDataTool } from '@/tools/market_data' import { requestTool as httpRequest } from '@/tools/http' import { huggingfaceChatTool } from '@/tools/huggingface' import { @@ -86,6 +85,7 @@ import { } from '@/tools/knowledge' import { linearCreateIssueTool, linearReadIssuesTool } from '@/tools/linear' import { linkupSearchTool } from '@/tools/linkup' +import { historicalDataTool } from '@/tools/market_data' import { mem0AddMemoriesTool, mem0GetMemoriesTool, mem0SearchMemoriesTool } from '@/tools/mem0' import { memoryAddTool, memoryDeleteTool, memoryGetAllTool, memoryGetTool } from '@/tools/memory' import { @@ -144,6 +144,13 @@ import { pineconeSearchVectorTool, pineconeUpsertTextTool, } from '@/tools/pinecone' +import { + deleteTool as postgresDeleteTool, + executeTool as postgresExecuteTool, + insertTool as postgresInsertTool, + queryTool as postgresQueryTool, + updateTool as postgresUpdateTool, +} from '@/tools/postgresql' import { posthogBatchEventsTool, posthogCaptureEventTool, @@ -189,13 +196,6 @@ import { posthogUpdatePropertyDefinitionTool, posthogUpdateSurveyTool, } from '@/tools/posthog' -import { - deleteTool as postgresDeleteTool, - executeTool as postgresExecuteTool, - insertTool as postgresInsertTool, - queryTool as postgresQueryTool, - updateTool as postgresUpdateTool, -} from '@/tools/postgresql' import { qdrantFetchTool, qdrantSearchTool, qdrantUpsertTool } from '@/tools/qdrant' import { redditGetCommentsTool, redditGetPostsTool, redditHotPostsTool } from '@/tools/reddit' import { mailSendTool } from '@/tools/resend' @@ -220,12 +220,6 @@ import { import { slackCanvasTool, slackMessageReaderTool, slackMessageTool } from '@/tools/slack' import { smsSendTool } from '@/tools/sms' import { stagehandAgentTool, stagehandExtractTool } from '@/tools/stagehand' -import { - orderHistoryTool, - tradingActionTool, - tradingHoldingsTool, - tradingOrderDetailTool, -} from '@/tools/trading' import { supabaseDeleteTool, supabaseGetRowTool, @@ -246,10 +240,22 @@ import { telegramSendVideoTool, } from '@/tools/telegram' import { thinkingTool } from '@/tools/thinking' +import { + orderHistoryTool, + tradingActionTool, + tradingHoldingsTool, + tradingOrderDetailTool, +} from '@/tools/trading' import { sendSMSTool } from '@/tools/twilio' import { typeformFilesTool, typeformInsightsTool, typeformResponsesTool } from '@/tools/typeform' import type { ToolConfig } from '@/tools/types' import { visionTool } from '@/tools/vision' +import { + watchlistAddListingTool, + watchlistReadListItemsTool, + watchlistReadListsTool, + watchlistRemoveListingTool, +} from '@/tools/watchlist' import { wealthboxReadContactTool, wealthboxReadNoteTool, @@ -457,6 +463,10 @@ export const tools: Record = { trading_get_holdings: tradingHoldingsTool, trading_order_detail: tradingOrderDetailTool, trading_order_history: orderHistoryTool, + watchlist_read_lists: watchlistReadListsTool, + watchlist_read_list_items: watchlistReadListItemsTool, + watchlist_add_listing: watchlistAddListingTool, + watchlist_remove_listing: watchlistRemoveListingTool, stagehand_extract: stagehandExtractTool, stagehand_agent: stagehandAgentTool, mem0_add_memories: mem0AddMemoriesTool, diff --git a/apps/tradinggoose/tools/trading/action.ts b/apps/tradinggoose/tools/trading/action.ts index 35619a74a..23b7852e9 100644 --- a/apps/tradinggoose/tools/trading/action.ts +++ b/apps/tradinggoose/tools/trading/action.ts @@ -1,5 +1,8 @@ import { stableStringifyJsonValue } from '@/lib/json/stable' -import { LISTING_IDENTITY_VALUE_TYPE, parseListingIdentityValueStrict } from '@/lib/listing/identity' +import { + LISTING_IDENTITY_VALUE_TYPE, + parseListingIdentityValueStrict, +} from '@/lib/listing/identity' import type { TradingActionResponse } from '@/providers/trading/types' import type { TradingActionParams } from '@/tools/trading/types' import type { ToolConfig } from '@/tools/types' @@ -70,6 +73,12 @@ export const tradingActionTool: ToolConfig { + provider?: TradingProviderId _context?: { workspaceId?: string workflowId?: string diff --git a/apps/tradinggoose/tools/watchlist/index.test.ts b/apps/tradinggoose/tools/watchlist/index.test.ts new file mode 100644 index 000000000..edb3e37ca --- /dev/null +++ b/apps/tradinggoose/tools/watchlist/index.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import { + watchlistAddListingTool, + watchlistReadListItemsTool, + watchlistReadListsTool, + watchlistRemoveListingTool, +} from '@/tools/watchlist' + +const context = { + _context: { + workspaceId: 'workspace-1', + }, +} + +const listing = { + listing_id: 'AAPL', + base_id: '', + quote_id: '', + listing_type: 'default', +} +const params = { ...context, watchlistId: 'watchlist-1', listing } + +const toolUrl = (tool: typeof watchlistAddListingTool | typeof watchlistRemoveListingTool) => + typeof tool.request.url === 'function' ? tool.request.url(params) : tool.request.url + +describe('watchlist tools', () => { + it('builds list requests against the canonical watchlists route', () => { + expect(watchlistReadListsTool.request.url).toBe('/api/watchlists') + expect(watchlistReadListsTool.request.method).toBe('GET') + expect(watchlistReadListsTool.request.body).toBeUndefined() + }) + + it('builds scoped add-listing requests', () => { + expect(toolUrl(watchlistAddListingTool)).toBe('/api/watchlists/watchlist-1/items') + expect(watchlistAddListingTool.request.body?.(params)).toEqual({ + workspaceId: 'workspace-1', + action: 'addListing', + listing, + }) + }) + + it('builds scoped remove-listing requests with listing identity', () => { + expect(toolUrl(watchlistRemoveListingTool)).toBe('/api/watchlists/watchlist-1/items') + expect(watchlistRemoveListingTool.request.body?.(params)).toEqual({ + workspaceId: 'workspace-1', + action: 'removeListing', + listing, + }) + }) + + it('maps read-list-items from the canonical watchlists response', async () => { + const response = new Response( + JSON.stringify({ + watchlists: [ + { + id: 'watchlist-1', + items: [ + { id: 'section-1', type: 'section', label: 'Tech' }, + { id: 'listing-1', type: 'listing', listing }, + ], + }, + ], + }) + ) + + const result = await watchlistReadListItemsTool.transformResponse?.(response, { + ...params, + }) + + expect(result?.output).toMatchObject({ + watchlist: { id: 'watchlist-1' }, + items: expect.arrayContaining([expect.objectContaining({ id: 'listing-1' })]), + listings: [expect.objectContaining({ id: 'listing-1' })], + sections: [expect.objectContaining({ id: 'section-1' })], + }) + }) +}) diff --git a/apps/tradinggoose/tools/watchlist/index.ts b/apps/tradinggoose/tools/watchlist/index.ts new file mode 100644 index 000000000..5e8d4ceb2 --- /dev/null +++ b/apps/tradinggoose/tools/watchlist/index.ts @@ -0,0 +1,210 @@ +import { LISTING_IDENTITY_VALUE_TYPE } from '@/lib/listing/identity' +import type { + WatchlistItem, + WatchlistListingItem, + WatchlistRecord, + WatchlistSectionItem, +} from '@/lib/watchlists/types' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +type WatchlistScopedParams = { + _context?: { workspaceId?: string } +} + +type WatchlistReadListItemsParams = WatchlistScopedParams & { watchlistId: string } +type WatchlistListingParams = WatchlistReadListItemsParams & { listing: unknown } + +type WatchlistListItemsOutput = { + watchlist: WatchlistRecord + items: WatchlistItem[] + listings: WatchlistListingItem[] + sections: WatchlistSectionItem[] +} + +type WatchlistListsOutput = { + watchlists: WatchlistRecord[] +} + +type WatchlistToolResponse> = ToolResponse & { + output: T +} + +type WatchlistOperation = 'readLists' | 'readListItems' | 'addListing' | 'removeListing' + +export const WATCHLIST_TOOL_IDS = { + readLists: 'watchlist_read_lists', + readListItems: 'watchlist_read_list_items', + addListing: 'watchlist_add_listing', + removeListing: 'watchlist_remove_listing', +} as const satisfies Record + +const jsonHeaders = () => ({ + 'Content-Type': 'application/json', +}) + +const readWatchlistsRequest = { + url: '/api/watchlists', + method: 'GET' as const, + headers: jsonHeaders, +} + +const resolveWorkspaceId = (params: WatchlistScopedParams, toolId: string) => { + const workspaceId = params._context?.workspaceId?.trim() + if (!workspaceId) { + throw new Error(`${toolId} requires workspace execution context`) + } + return workspaceId +} + +const watchlistItemsUrl = (watchlistId: string) => + `/api/watchlists/${encodeURIComponent(watchlistId)}/items` + +const splitWatchlistItems = (items: WatchlistItem[]) => { + const listings: WatchlistListingItem[] = [] + const sections: WatchlistSectionItem[] = [] + + for (const item of items) { + if (item.type === 'listing') listings.push(item) + else sections.push(item) + } + + return { items, listings, sections } +} + +const watchlistOutput = (watchlist: WatchlistRecord): WatchlistListItemsOutput => ({ + watchlist, + ...splitWatchlistItems(watchlist.items), +}) + +const transformReadListsResponse = async ( + response: Response +): Promise> => ({ + success: true, + output: (await response.json()) as WatchlistListsOutput, +}) + +const transformReadListItemsResponse = async ( + response: Response, + params?: WatchlistReadListItemsParams +): Promise> => { + const { watchlists } = (await response.json()) as WatchlistListsOutput + const watchlist = watchlists.find((entry) => entry.id === params?.watchlistId) + if (!watchlist) throw new Error('Watchlist not found') + return { success: true, output: watchlistOutput(watchlist) } +} + +const transformWatchlistResponse = async ( + response: Response +): Promise> => { + const { watchlist } = (await response.json()) as { watchlist: WatchlistRecord } + return { success: true, output: watchlistOutput(watchlist) } +} + +const workspaceReadExecution = { + workspace: { required: true, access: 'read' }, +} as const + +const workspaceWriteExecution = { + workspace: { required: true, access: 'write' }, +} as const + +const watchlistIdParam = { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Watchlist ID returned by Watchlist: Read Lists.', +} as const + +const listingParam = { + type: LISTING_IDENTITY_VALUE_TYPE, + required: true, + visibility: 'user-or-llm', + description: 'Structured TradingGoose listing identity.', +} as const + +const watchlistListItemsOutputs = { + watchlist: { type: 'json', description: 'Watchlist record.' }, + items: { + type: 'array', + description: 'Watchlist items in display order, including listings and sections.', + }, + listings: { type: 'array', description: 'Listing items in the watchlist.' }, + sections: { type: 'array', description: 'Section/category items in the watchlist.' }, +} as const + +export const watchlistReadListsTool: ToolConfig< + WatchlistScopedParams, + WatchlistToolResponse +> = { + id: WATCHLIST_TOOL_IDS.readLists, + name: 'Watchlist: Read Lists', + description: 'Read watchlists available in the current workspace for the executing user.', + version: '1.0.0', + execution: workspaceReadExecution, + params: {}, + request: readWatchlistsRequest, + transformResponse: transformReadListsResponse, + outputs: { + watchlists: { + type: 'array', + description: 'Watchlist records including items, settings, and metadata.', + }, + }, +} + +export const watchlistReadListItemsTool: ToolConfig< + WatchlistReadListItemsParams, + WatchlistToolResponse +> = { + id: WATCHLIST_TOOL_IDS.readListItems, + name: 'Watchlist: Read List Items', + description: 'Read one watchlist with ordered listings and section/category items.', + version: '1.0.0', + execution: workspaceReadExecution, + params: { + watchlistId: watchlistIdParam, + }, + request: readWatchlistsRequest, + transformResponse: transformReadListItemsResponse, + outputs: watchlistListItemsOutputs, +} + +const listingMutationTool = ( + operation: 'addListing' | 'removeListing', + name: string, + description: string +): ToolConfig> => ({ + id: WATCHLIST_TOOL_IDS[operation], + name, + description, + version: '1.0.0', + execution: workspaceWriteExecution, + params: { + watchlistId: watchlistIdParam, + listing: listingParam, + }, + request: { + url: (params) => watchlistItemsUrl(params.watchlistId), + method: 'POST', + headers: jsonHeaders, + body: (params) => ({ + workspaceId: resolveWorkspaceId(params, WATCHLIST_TOOL_IDS[operation]), + action: operation, + listing: params.listing, + }), + }, + transformResponse: transformWatchlistResponse, + outputs: watchlistListItemsOutputs, +}) + +export const watchlistAddListingTool = listingMutationTool( + 'addListing', + 'Watchlist: Add Listing', + 'Add a listing to a watchlist.' +) + +export const watchlistRemoveListingTool = listingMutationTool( + 'removeListing', + 'Watchlist: Remove Listing', + 'Remove a listing from a watchlist.' +) diff --git a/apps/tradinggoose/widgets/utils/workflow-selection.ts b/apps/tradinggoose/widgets/utils/workflow-selection.ts index bc7da366b..a3751da70 100644 --- a/apps/tradinggoose/widgets/utils/workflow-selection.ts +++ b/apps/tradinggoose/widgets/utils/workflow-selection.ts @@ -1,10 +1,11 @@ -import { useEffect } from 'react' -import type { WidgetInstance } from '@/widgets/layout' -import type { PairColor } from '@/widgets/pair-colors' +import { useEffect, useRef } from 'react' +import { isEqual } from 'lodash' import { WORKFLOW_WIDGET_SELECT_WORKFLOW_EVENT, type WorkflowWidgetSelectEventDetail, } from '@/widgets/events' +import type { WidgetInstance } from '@/widgets/layout' +import type { PairColor } from '@/widgets/pair-colors' interface UseWorkflowSelectionPersistenceOptions { onWidgetParamsChange?: (params: Record | null) => void @@ -14,6 +15,27 @@ interface UseWorkflowSelectionPersistenceOptions { params?: Record | null } +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + +const normalizeString = (value: unknown) => { + if (typeof value !== 'string') return undefined + const trimmed = value.trim() + return trimmed || undefined +} + +const sanitizeWorkflowWidgetParams = ( + params: Record | null | undefined +): Record | null => { + if (!params || !isRecord(params)) return null + + const { workflowId: rawWorkflowId, ...restParams } = params + const workflowId = normalizeString(rawWorkflowId) + const nextParams = workflowId ? { ...restParams, workflowId } : restParams + + return Object.keys(nextParams).length > 0 ? nextParams : null +} + export function useWorkflowSelectionPersistence({ onWidgetParamsChange, panelId, @@ -21,24 +43,35 @@ export function useWorkflowSelectionPersistence({ pairColor = 'gray', params, }: UseWorkflowSelectionPersistenceOptions) { + const latestParamsRef = useRef | null>( + sanitizeWorkflowWidgetParams(params) + ) + useEffect(() => { - if (!onWidgetParamsChange || pairColor !== 'gray') { + latestParamsRef.current = sanitizeWorkflowWidgetParams(params) + }, [params]) + + useEffect(() => { + if (!onWidgetParamsChange) { return } const handleWorkflowSelect = (event: Event) => { const detail = (event as CustomEvent).detail if (!detail?.workflowId) return + if (pairColor !== 'gray') return if (panelId && detail.panelId && detail.panelId !== panelId) return if (widget?.key && detail.widgetKey && detail.widgetKey !== widget.key) return - const currentParams = - params && typeof params === 'object' ? (params as Record) : {} - - onWidgetParamsChange({ + const currentParams = latestParamsRef.current ?? {} + const nextParams = sanitizeWorkflowWidgetParams({ ...currentParams, workflowId: detail.workflowId, }) + + if (isEqual(currentParams, nextParams)) return + latestParamsRef.current = nextParams + onWidgetParamsChange(nextParams) } window.addEventListener( @@ -52,7 +85,7 @@ export function useWorkflowSelectionPersistence({ handleWorkflowSelect as EventListener ) } - }, [onWidgetParamsChange, panelId, pairColor, params, widget?.key]) + }, [onWidgetParamsChange, panelId, pairColor, widget?.key]) } interface EmitWorkflowSelectionOptions { diff --git a/apps/tradinggoose/widgets/widgets/components/listing-selector.test.tsx b/apps/tradinggoose/widgets/widgets/components/listing-selector.test.tsx deleted file mode 100644 index 8ac749f2d..000000000 --- a/apps/tradinggoose/widgets/widgets/components/listing-selector.test.tsx +++ /dev/null @@ -1,256 +0,0 @@ -/** - * @vitest-environment jsdom - */ - -import { act } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { ListingSelector } from '@/widgets/widgets/components/listing-selector' -import { useListingSelectorStore } from '@/stores/market/selector/store' -import type { ListingOption } from '@/lib/listing/identity' - -const reactActEnvironment = globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT?: boolean -} - -const fetchListingsMock = vi.fn() - -vi.mock('@/components/listing-selector/fetchers', () => ({ - fetchListings: (...args: Parameters) => fetchListingsMock(...args), -})) - -vi.mock('@/hooks/workflow/use-accessible-reference-prefixes', () => ({ - useAccessibleReferencePrefixes: () => undefined, -})) - -vi.mock('@/components/ui/tag-dropdown', () => ({ - checkTagTrigger: () => ({ show: false }), - TagDropdown: () => null, -})) - -vi.mock('@/components/ui/formatted-text', () => ({ - formatDisplayText: (value: string) => value, -})) - -vi.mock('@/components/ui/avatar', () => ({ - Avatar: ({ children }: { children: React.ReactNode }) =>
{children}
, - AvatarFallback: ({ children }: { children: React.ReactNode }) =>
{children}
, - AvatarImage: () => null, -})) - -vi.mock('@/components/listing-selector/selector/resolve-request', () => ({ - requestListingResolution: vi.fn(async () => null), -})) - -vi.mock('@/components/listing-selector/listing/rank-updates', () => ({ - triggerCryptoRankUpdate: vi.fn(), - triggerCurrencyRankUpdate: vi.fn(), - triggerListingRankUpdate: vi.fn(), -})) - -vi.mock('@/widgets/widgets/components/widget-header-control', () => ({ - widgetHeaderControlClassName: (className?: string) => - ['trigger', className].filter(Boolean).join(' '), -})) - -describe('ListingSelector', () => { - let container: HTMLDivElement - let root: Root - - beforeEach(() => { - vi.useFakeTimers() - reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true - fetchListingsMock.mockReset() - useListingSelectorStore.setState({ instances: {} }) - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) - }) - - afterEach(() => { - act(() => { - root.unmount() - }) - container.remove() - useListingSelectorStore.setState({ instances: {} }) - reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = false - vi.useRealTimers() - }) - - it('fires a debounced market search after the user types into the open selector', async () => { - fetchListingsMock.mockResolvedValue([]) - - await act(async () => { - root.render() - }) - - const input = container.querySelector('input') as HTMLInputElement | null - expect(input).toBeTruthy() - - await act(async () => { - input?.dispatchEvent(new FocusEvent('focus', { bubbles: true })) - }) - - await act(async () => { - if (!input) return - const valueSetter = Object.getOwnPropertyDescriptor( - HTMLInputElement.prototype, - 'value' - )?.set - valueSetter?.call(input, 'AAPL') - input.dispatchEvent(new Event('input', { bubbles: true })) - }) - - expect(fetchListingsMock).not.toHaveBeenCalled() - - await act(async () => { - vi.advanceTimersByTime(400) - await Promise.resolve() - }) - - expect(fetchListingsMock).toHaveBeenCalledTimes(1) - expect(fetchListingsMock).toHaveBeenCalledWith( - expect.objectContaining({ - search_query: 'AAPL', - }), - expect.any(AbortSignal) - ) - }) - - it('renders blank-open search results in the dropdown', async () => { - const apple: ListingOption = { - listing_id: 'TG_LSTG_E7581A', - base_id: '', - quote_id: '', - listing_type: 'default', - base: 'AAPL', - quote: 'USD', - name: 'Apple Inc.', - iconUrl: '', - assetClass: 'stock', - } - fetchListingsMock.mockResolvedValue([apple]) - - await act(async () => { - root.render() - }) - - const input = container.querySelector('input') as HTMLInputElement | null - expect(input).toBeTruthy() - - await act(async () => { - input?.focus() - await Promise.resolve() - await Promise.resolve() - }) - - await act(async () => { - await Promise.resolve() - }) - - expect(fetchListingsMock).toHaveBeenCalledTimes(1) - expect(document.body.textContent).toContain('AAPL') - expect(document.body.textContent).toContain('Apple Inc.') - }) - - it('renders fetched search results in the dropdown', async () => { - const apple: ListingOption = { - listing_id: 'TG_LSTG_E7581A', - base_id: '', - quote_id: '', - listing_type: 'default', - base: 'AAPL', - quote: 'USD', - name: 'Apple Inc.', - iconUrl: '', - assetClass: 'stock', - } - fetchListingsMock.mockResolvedValue([apple]) - - await act(async () => { - root.render() - }) - - const input = container.querySelector('input') as HTMLInputElement | null - expect(input).toBeTruthy() - - await act(async () => { - input?.dispatchEvent(new FocusEvent('focus', { bubbles: true })) - }) - - await act(async () => { - if (!input) return - const valueSetter = Object.getOwnPropertyDescriptor( - HTMLInputElement.prototype, - 'value' - )?.set - valueSetter?.call(input, 'AAPL') - input.dispatchEvent(new Event('input', { bubbles: true })) - vi.advanceTimersByTime(400) - await Promise.resolve() - await Promise.resolve() - }) - - expect(document.body.textContent).toContain('AAPL') - expect(document.body.textContent).toContain('Apple Inc.') - }) - - it('clears the existing selection when the user types a different query', async () => { - const selectedListing: ListingOption = { - listing_id: 'TG_LSTG_E7581A', - base_id: '', - quote_id: '', - listing_type: 'default', - base: 'AAPL', - quote: 'USD', - name: 'Apple Inc.', - iconUrl: '', - assetClass: 'stock', - } - - useListingSelectorStore.setState({ - instances: { - 'listing-selector-selected-test': { - query: 'AAPL', - results: [], - isLoading: false, - error: undefined, - selectedListing, - selectedListingValue: { - listing_id: selectedListing.listing_id, - base_id: '', - quote_id: '', - listing_type: 'default', - }, - }, - }, - }) - - await act(async () => { - root.render() - }) - - const input = container.querySelector('input') as HTMLInputElement | null - expect(input).toBeTruthy() - - await act(async () => { - input?.dispatchEvent(new FocusEvent('focus', { bubbles: true })) - }) - - await act(async () => { - if (!input) return - const valueSetter = Object.getOwnPropertyDescriptor( - HTMLInputElement.prototype, - 'value' - )?.set - valueSetter?.call(input, 'MSFT') - input.dispatchEvent(new Event('input', { bubbles: true })) - }) - - const instance = - useListingSelectorStore.getState().instances['listing-selector-selected-test'] - expect(instance?.selectedListing).toBeNull() - expect(instance?.selectedListingValue).toBeNull() - expect(instance?.query).toBe('MSFT') - }) -}) diff --git a/apps/tradinggoose/widgets/widgets/components/listing-selector.tsx b/apps/tradinggoose/widgets/widgets/components/listing-selector.tsx deleted file mode 100644 index a6916abf8..000000000 --- a/apps/tradinggoose/widgets/widgets/components/listing-selector.tsx +++ /dev/null @@ -1,584 +0,0 @@ -'use client' - -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { ChevronDown } from 'lucide-react' -import { createPortal } from 'react-dom' -import { - triggerCryptoRankUpdate, - triggerCurrencyRankUpdate, - triggerListingRankUpdate, -} from '@/components/listing-selector/listing/rank-updates' -import { - getListingDisplaySymbol, - ListingDisplayRow, -} from '@/components/listing-selector/listing/row' -import { requestListingResolution } from '@/components/listing-selector/selector/resolve-request' -import { useMarketListingSearch } from '@/components/listing-selector/selector/use-listing-search' -import { formatDisplayText } from '@/components/ui/formatted-text' -import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown' -import { - areListingIdentitiesEqual, - getListingIdentityKey, - LISTING_IDENTITY_VALUE_TYPE, - type ListingIdentity, - type ListingOption, - toListingValue, - toListingValueObject, -} from '@/lib/listing/identity' -import { cn } from '@/lib/utils' -import { useAccessibleReferencePrefixes } from '@/hooks/workflow/use-accessible-reference-prefixes' -import { - createEmptyListingSelectorInstance, - useListingSelectorStore, -} from '@/stores/market/selector/store' -import { widgetHeaderControlClassName } from '@/widgets/widgets/components/widget-header-control' - -interface ListingSelectorProps { - instanceId: string - blockId?: string - disabled?: boolean - className?: string - providerType?: 'market' | 'trading' - activateOnMount?: boolean - onListingChange?: (listing: ListingOption | null) => void - onListingValueChange?: (value: string | null) => void - onListingTagSelect?: (value: string) => void -} - -const getListingOptionKey = (listing: ListingOption) => - `${getListingIdentityKey(listing)}|${listing.base ?? ''}|${listing.quote ?? ''}|${listing.name ?? ''}` - -const hasListingDetails = (listing?: ListingOption | null): boolean => { - if (!listing) return false - const base = listing.base?.trim() - if (!base) return false - if (listing.listing_type === 'default') return true - const quote = listing.quote?.trim() - return Boolean(quote) -} - -export function ListingSelector({ - instanceId, - blockId, - disabled, - className, - providerType = 'market', - activateOnMount = false, - onListingChange, - onListingValueChange, - onListingTagSelect, -}: ListingSelectorProps) { - const containerRef = useRef(null) - const ensureInstance = useListingSelectorStore((state) => state.ensureInstance) - const updateInstance = useListingSelectorStore((state) => state.updateInstance) - const instance = useListingSelectorStore((state) => state.instances[instanceId]) - - useEffect(() => { - ensureInstance(instanceId) - }, [ensureInstance, instanceId]) - - const safeInstance = instance ?? createEmptyListingSelectorInstance() - const { query, results, isLoading, error, selectedListing, providerId } = safeInstance - - const [open, setOpen] = useState(false) - const inputRef = useRef(null) - const [highlightedIndex, setHighlightedIndex] = useState(-1) - const [showTags, setShowTags] = useState(false) - const [cursorPosition, setCursorPosition] = useState(0) - const [variableCommitted, setVariableCommitted] = useState(false) - const [portalTarget, setPortalTarget] = useState(null) - const [dropdownPosition, setDropdownPosition] = useState<{ - top: number - left: number - width: number - } | null>(null) - const hydratedListingRef = useRef(null) - const hydrateRequestRef = useRef(0) - const hasActivatedOnMountRef = useRef(false) - const accessiblePrefixes = useAccessibleReferencePrefixes(blockId) - - const isVariableListingInput = useCallback((value: string) => { - const trimmed = value.trim() - if (!trimmed) return false - return trimmed.startsWith('<') - }, []) - - const commitVariableValue = (value: string, source: 'input' | 'tag' = 'input') => { - updateInstance(instanceId, { - query: value, - results: [], - isLoading: false, - error: undefined, - selectedListingValue: null, - selectedListing: null, - }) - setVariableCommitted(true) - if (source === 'tag') { - onListingTagSelect?.(value) - onListingValueChange?.(value) - return - } - onListingValueChange?.(value) - } - - const clearVariableValue = () => { - updateInstance(instanceId, { - query: '', - results: [], - isLoading: false, - error: undefined, - selectedListingValue: null, - selectedListing: null, - }) - setVariableCommitted(false) - onListingValueChange?.(null) - } - - useMarketListingSearch({ - open, - query, - providerId, - providerType, - instanceId, - updateInstance, - isVariableInput: isVariableListingInput, - }) - - const selectedLabel = useMemo(() => { - if (!selectedListing) return '' - return getListingDisplaySymbol(selectedListing) - }, [selectedListing]) - - const selectedListingIdentity = useMemo( - () => toListingValueObject(safeInstance.selectedListingValue ?? selectedListing ?? null), - [safeInstance.selectedListingValue, selectedListing] - ) - const hasUnresolvedSelection = Boolean(selectedListingIdentity) && !selectedListing - const fallbackLabel = '' - const displayValue = open ? query : selectedLabel || fallbackLabel || query - const showRichOverlay = !open && !!selectedListing - const showTagOverlay = !open && !selectedListing && Boolean(query?.trim().includes('<')) - const showListingDropdown = open && !showTags - const showPlaceholderOverlay = - !open && !selectedListing && !query?.trim() && !hasUnresolvedSelection - const hideInputText = showRichOverlay || showTagOverlay || showPlaceholderOverlay - - const handleSelect = (listing: ListingOption) => { - const nextLabel = getListingDisplaySymbol(listing) - updateInstance(instanceId, { - selectedListingValue: toListingValue(listing), - selectedListing: listing, - query: nextLabel, - results: [], - error: undefined, - }) - setOpen(false) - setHighlightedIndex(-1) - setShowTags(false) - setVariableCommitted(false) - triggerListingRankUpdate(listing) - const listingType = listing.listing_type - if (listingType === 'crypto' && listing.base_id) { - triggerCryptoRankUpdate(listing.base_id) - } - if (listingType === 'currency' && listing.base_id) { - triggerCurrencyRankUpdate(listing.base_id) - } - onListingChange?.(listing) - } - - const handleTagSelect = (value: string) => { - const lastOpen = value.lastIndexOf('<') - const lastClose = value.indexOf('>', lastOpen + 1) - const rawTag = - lastOpen >= 0 ? value.slice(lastOpen + 1, lastClose >= 0 ? lastClose : value.length) : value - const trimmedTag = rawTag.trim() - const normalizedValue = trimmedTag ? `<${trimmedTag}>` : value - commitVariableValue(normalizedValue, 'tag') - setShowTags(false) - setOpen(false) - setHighlightedIndex(-1) - setCursorPosition(normalizedValue.length) - } - - useEffect(() => { - if (!open) return - const timer = setTimeout(() => { - inputRef.current?.focus() - }, 0) - return () => clearTimeout(timer) - }, [open]) - - useEffect(() => { - if (!activateOnMount || disabled || hasActivatedOnMountRef.current) return - hasActivatedOnMountRef.current = true - const nextQuery = query || selectedLabel - if (nextQuery && query !== nextQuery) { - updateInstance(instanceId, { query: nextQuery }) - } - setCursorPosition(nextQuery.length) - setShowTags(false) - setHighlightedIndex(-1) - setOpen(true) - }, [activateOnMount, disabled, instanceId, query, selectedLabel, updateInstance]) - - useEffect(() => { - const selectedValue = safeInstance.selectedListingValue ?? safeInstance.selectedListing ?? null - if (!selectedValue) { - hydratedListingRef.current = null - return - } - - const identity = toListingValueObject(selectedValue) - if (!identity) return - - if (safeInstance.selectedListing && hasListingDetails(safeInstance.selectedListing)) { - hydratedListingRef.current = identity - return - } - - if (areListingIdentitiesEqual(hydratedListingRef.current, identity)) { - return - } - - hydratedListingRef.current = identity - const requestId = ++hydrateRequestRef.current - let cancelled = false - - requestListingResolution(identity) - .then((resolved) => { - if (cancelled || hydrateRequestRef.current !== requestId) return - if (!resolved) return - updateInstance(instanceId, { - selectedListing: resolved, - selectedListingValue: identity, - }) - }) - .catch(() => {}) - - return () => { - cancelled = true - } - }, [safeInstance.selectedListing, safeInstance.selectedListingValue, instanceId, updateInstance]) - - useEffect(() => { - if (typeof document === 'undefined') return - setPortalTarget(document.body) - }, []) - - useEffect(() => { - if (!showListingDropdown) { - setDropdownPosition(null) - return - } - - const updatePosition = () => { - const container = containerRef.current - if (!container) return - const rect = container.getBoundingClientRect() - setDropdownPosition({ - top: rect.bottom + window.scrollY + 4, - left: rect.left + window.scrollX, - width: rect.width, - }) - } - - updatePosition() - window.addEventListener('resize', updatePosition) - window.addEventListener('scroll', updatePosition, true) - return () => { - window.removeEventListener('resize', updatePosition) - window.removeEventListener('scroll', updatePosition, true) - } - }, [showListingDropdown]) - - useEffect(() => { - if (open) return - const nextLabel = selectedLabel || fallbackLabel - if (!nextLabel) return - if (query === nextLabel) return - updateInstance(instanceId, { query: nextLabel }) - }, [open, query, selectedLabel, fallbackLabel, instanceId, updateInstance]) - - useEffect(() => { - setHighlightedIndex((prev) => { - if (prev >= 0 && prev < results.length) { - return prev - } - return -1 - }) - }, [results]) - - const dropdown = showListingDropdown ? ( -
event.stopPropagation()} - > -
-
event.stopPropagation()} - onTouchMove={(event) => event.stopPropagation()} - > - {isLoading ? ( -
Searching...
- ) : results.length === 0 ? ( -
- {error || 'No listings found.'} -
- ) : ( - results.map((listing, index) => { - const isHighlighted = index === highlightedIndex - return ( -
setHighlightedIndex(index)} - onMouseDown={(event) => { - event.preventDefault() - handleSelect(listing) - }} - className={cn( - 'flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground', - isHighlighted && 'bg-accent text-accent-foreground' - )} - > - -
- ) - }) - )} -
-
-
- ) : null - - return ( -
-
- { - if (disabled) return - const nextValue = event.target.value - const newCursorPosition = event.target.selectionStart ?? nextValue.length - setCursorPosition(newCursorPosition) - const tagTrigger = blockId - ? checkTagTrigger(nextValue, newCursorPosition) - : { show: false } - setShowTags(Boolean(blockId) && tagTrigger.show) - - if (!nextValue.trim()) { - setShowTags(false) - if (selectedListing || safeInstance.selectedListingValue) { - updateInstance(instanceId, { - query: '', - results: [], - isLoading: false, - error: undefined, - }) - return - } - clearVariableValue() - return - } - - const isVariable = isVariableListingInput(nextValue) - if (!isVariable && variableCommitted) { - setVariableCommitted(false) - onListingValueChange?.(null) - } - - if (isVariable) { - commitVariableValue(nextValue) - return - } - - setOpen(true) - setHighlightedIndex(-1) - const patch: Partial = { query: nextValue } - if (selectedListing && selectedLabel && nextValue.trim() !== selectedLabel) { - patch.selectedListingValue = null - patch.selectedListing = null - } - updateInstance(instanceId, patch) - }} - onFocus={() => { - if (disabled) return - setOpen(true) - setHighlightedIndex(-1) - const position = inputRef.current?.selectionStart ?? query.length - setCursorPosition(position) - const tagTrigger = blockId ? checkTagTrigger(query, position) : { show: false } - setShowTags(Boolean(blockId) && tagTrigger.show) - }} - onBlur={() => { - if (disabled) return - setTimeout(() => { - const activeElement = document.activeElement - if (!activeElement || !activeElement.closest('[data-market-selector]')) { - if (isVariableListingInput(query)) { - commitVariableValue(query) - } - setOpen(false) - setHighlightedIndex(-1) - if (selectedLabel && query !== selectedLabel) { - updateInstance(instanceId, { query: selectedLabel }) - } - } - }, 150) - }} - onKeyDown={(event) => { - if (event.key === 'Escape') { - setOpen(false) - setHighlightedIndex(-1) - setShowTags(false) - return - } - - if (showTags) { - return - } - - if (event.key === 'ArrowDown') { - event.preventDefault() - if (!open) { - setOpen(true) - if (results.length > 0) { - setHighlightedIndex(0) - } - } else if (results.length > 0) { - setHighlightedIndex((prev) => (prev < results.length - 1 ? prev + 1 : 0)) - } - } - - if (event.key === 'ArrowUp') { - event.preventDefault() - if (open && results.length > 0) { - setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : results.length - 1)) - } - } - - if (event.key === 'Enter' && open && highlightedIndex >= 0) { - event.preventDefault() - const selected = results[highlightedIndex] - if (selected) { - handleSelect(selected) - } - return - } - - if (event.key === 'Enter' && isVariableListingInput(query)) { - event.preventDefault() - commitVariableValue(query) - setOpen(false) - setHighlightedIndex(-1) - } - }} - disabled={disabled} - /> - {showRichOverlay ? ( -
- -
- ) : null} - {showPlaceholderOverlay ? ( -
- -
- ) : null} - {showTagOverlay ? ( -
-
- {formatDisplayText(query, { - accessiblePrefixes, - highlightAll: !accessiblePrefixes, - })} -
-
- ) : null} - -
- - {dropdown - ? portalTarget && dropdownPosition - ? createPortal(dropdown, portalTarget) - : dropdown - : null} - - {blockId ? ( - { - setShowTags(false) - }} - /> - ) : null} -
- ) -} diff --git a/apps/tradinggoose/widgets/widgets/data_chart/components/listing-control.tsx b/apps/tradinggoose/widgets/widgets/data_chart/components/listing-control.tsx index 7d2d1046d..1cff63f01 100644 --- a/apps/tradinggoose/widgets/widgets/data_chart/components/listing-control.tsx +++ b/apps/tradinggoose/widgets/widgets/data_chart/components/listing-control.tsx @@ -1,21 +1,21 @@ 'use client' import { useEffect, useMemo, useRef } from 'react' -import { - createEmptyListingSelectorInstance, - useListingSelectorStore, -} from '@/stores/market/selector/store' +import { hasListingDisplayDetails } from '@/components/listing-selector/listing/row' +import { ListingSearchInput } from '@/components/listing-selector/selector/input' import { areListingIdentitiesEqual, type ListingIdentity, + type ListingOption, toListingValue, toListingValueObject, - type ListingOption, } from '@/lib/listing/identity' import { usePairColorContext, useSetPairColorContext } from '@/stores/dashboard/pair-store' +import { + createEmptyListingSelectorInstance, + useListingSelectorStore, +} from '@/stores/market/selector/store' import type { PairColor } from '@/widgets/pair-colors' -import { ListingSelector } from '@/widgets/widgets/components/listing-selector' -import { hasListingDetails } from '@/widgets/widgets/data_chart/utils/listing-utils' import { emitDataChartParamsChange } from '@/widgets/utils/chart-params' import type { DataChartWidgetParams } from '@/widgets/widgets/data_chart/types' @@ -38,8 +38,9 @@ export const DataChartListingSelector = ({ onListingChange, }: DataChartListingSelectorProps) => (
- @@ -54,7 +55,7 @@ export const DataChartListingControl = ({ }: DataChartListingControlProps) => { const providerId = params.data?.provider const pairContext = usePairColorContext(pairColor) - const rawListing = pairColor !== 'gray' ? pairContext.listing ?? null : params.listing ?? null + const rawListing = pairColor !== 'gray' ? (pairContext.listing ?? null) : (params.listing ?? null) const listingIdentity = useMemo(() => { if (!rawListing || typeof rawListing !== 'object') return null return toListingValueObject(rawListing) @@ -62,7 +63,7 @@ export const DataChartListingControl = ({ const displayListing = useMemo(() => { if (!rawListing || typeof rawListing !== 'object') return null const candidate = rawListing as ListingOption - return hasListingDetails(candidate) ? candidate : null + return hasListingDisplayDetails(candidate) ? candidate : null }, [rawListing]) const ensureInstance = useListingSelectorStore((state) => state.ensureInstance) const updateInstance = useListingSelectorStore((state) => state.updateInstance) @@ -106,7 +107,8 @@ export const DataChartListingControl = ({ useEffect(() => { const normalizedProvider = providerId ?? undefined const previousProvider = previousProviderRef.current - const providerChanged = previousProvider !== undefined && previousProvider !== normalizedProvider + const providerChanged = + previousProvider !== undefined && previousProvider !== normalizedProvider if (providerChanged) { updateInstance(instanceId, { diff --git a/apps/tradinggoose/widgets/widgets/data_chart/utils/listing-utils.ts b/apps/tradinggoose/widgets/widgets/data_chart/utils/listing-utils.ts index d59c8fe2e..9415d389b 100644 --- a/apps/tradinggoose/widgets/widgets/data_chart/utils/listing-utils.ts +++ b/apps/tradinggoose/widgets/widgets/data_chart/utils/listing-utils.ts @@ -5,17 +5,6 @@ export type ListingSymbolParts = { quote: string } -export const hasListingDetails = (listing?: ListingResolved | null): boolean => { - if (!listing) return false - const base = listing.base?.trim() - const name = listing.name?.trim() - if (listing.listing_type === 'default') { - return Boolean(base || name) - } - const quote = listing.quote?.trim() - return Boolean((base && quote) || name) -} - export const getListingSymbol = (listing: ListingResolved): string => { const base = listing.base?.trim() const quote = listing.quote?.trim() diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/dropdown.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/dropdown.tsx index ec792c8cb..e15aa4d0a 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/dropdown.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/dropdown.tsx @@ -139,6 +139,7 @@ export function Dropdown({ const options = await fetchOptions(blockId, subBlockId, { channelId: resolvedChannelId, workflowId: resolvedWorkflowId ?? null, + workspaceId: routeContext?.workspaceId, contextValues: resolvedContextValues as Record | undefined, }) setFetchedOptions(options) @@ -159,6 +160,7 @@ export function Dropdown({ blockContextValues, resolvedChannelId, resolvedWorkflowId, + routeContext?.workspaceId, ]) const evaluatedOptions = useMemo(() => { @@ -245,13 +247,7 @@ export function Dropdown({ if (!isValid) { clearSelectedValue() } - }, [ - optionsReady, - hasValue, - availableOptions, - value, - clearSelectedValue, - ]) + }, [optionsReady, hasValue, availableOptions, value, clearSelectedValue]) // Mark store as initialized on first render useEffect(() => { diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/listing-selector/listing-selector.test.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/listing-selector/listing-selector.test.tsx new file mode 100644 index 000000000..5d9cd1bb6 --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/listing-selector/listing-selector.test.tsx @@ -0,0 +1,156 @@ +/** + * @vitest-environment jsdom + */ + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SubBlockConfig } from '@/blocks/types' +import { useListingSelectorStore } from '@/stores/market/selector/store' +import { ListingSelectorInput } from './listing-selector' + +const listingSelectorMock = vi.hoisted(() => vi.fn()) +const subBlockValues = vi.hoisted(() => new Map()) +const setSubBlockValueMock = vi.hoisted(() => vi.fn()) +const reactActEnvironment = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } + +vi.mock('@/components/listing-selector/selector/combo', () => ({ + ListingSelector: (props: Record) => { + listingSelectorMock(props) + return null + }, +})) + +vi.mock( + '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/hooks/use-sub-block-value', + () => ({ + useSubBlockValue: (_blockId: string, subBlockId: string) => [ + subBlockValues.get(subBlockId) ?? null, + setSubBlockValueMock, + ], + }) +) + +vi.mock( + '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/hooks/use-depends-on-gate', + () => ({ + useDependsOnGate: () => ({ finalDisabled: false }), + }) +) + +vi.mock('@/widgets/widgets/editor_workflow/context/workflow-route-context', () => ({ + useOptionalWorkflowRoute: () => ({ + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + channelId: 'channel-1', + }), +})) + +vi.mock('@/hooks/use-tag-selection', () => ({ + useTagSelection: () => vi.fn(), +})) + +const config = { + id: 'listing', + title: 'Listing', + type: 'market-selector', + providerType: 'market', + tradingProviderFieldId: 'provider', + dependsOn: ['provider'], +} satisfies SubBlockConfig + +const unscopedConfig = { + id: 'listing', + title: 'Listing', + type: 'market-selector', + providerType: 'market', +} satisfies SubBlockConfig + +describe('ListingSelectorInput', () => { + let root: Root + let container: HTMLDivElement + + beforeEach(() => { + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true + listingSelectorMock.mockClear() + setSubBlockValueMock.mockClear() + subBlockValues.clear() + useListingSelectorStore.setState({ instances: {} }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + useListingSelectorStore.setState({ instances: {} }) + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = false + }) + + it('enables market selectors with a selected trading provider when the route market provider is empty', () => { + subBlockValues.set('provider', 'alpaca') + + act(() => { + root.render() + }) + + expect(listingSelectorMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + disabled: false, + marketProviderId: undefined, + tradingProviderId: 'alpaca', + }) + ) + }) + + it('enables market selectors without provider filters', () => { + act(() => { + root.render( + + ) + }) + + expect(listingSelectorMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + disabled: false, + marketProviderId: undefined, + tradingProviderId: undefined, + }) + ) + }) + + it('keeps empty fetched listing candidates stable while options load', () => { + const fetchedConfig = { + ...unscopedConfig, + fetchOptionsCondition: { field: 'operation', value: 'removeListing' }, + fetchOptions: vi.fn(() => new Promise(() => {})), + } satisfies SubBlockConfig + const props = { + blockId: 'block-1', + subBlockId: 'listing', + config: fetchedConfig, + contextValues: { operation: 'removeListing' }, + } + + act(() => { + root.render() + }) + const firstCandidates = listingSelectorMock.mock.calls.at(-1)?.[0]?.candidateListings + + act(() => { + root.render( + + ) + }) + const secondCandidates = listingSelectorMock.mock.calls.at(-1)?.[0]?.candidateListings + + expect(firstCandidates).toEqual([]) + expect(secondCandidates).toBe(firstCandidates) + expect(fetchedConfig.fetchOptions).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/listing-selector/listing-selector.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/listing-selector/listing-selector.tsx index 7bdd397ae..ec6faa90d 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/listing-selector/listing-selector.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/listing-selector/listing-selector.tsx @@ -1,19 +1,23 @@ -import { useEffect, useMemo, useRef } from 'react' -import type { SubBlockConfig } from '@/blocks/types' +import { useEffect, useMemo, useRef, useState } from 'react' import { ListingSelector } from '@/components/listing-selector/selector/combo' import { areListingIdentitiesEqual, + type ListingInputValue, type ListingOption, toListingValue, toListingValueObject, - type ListingInputValue, } from '@/lib/listing/identity' +import { evaluateSubBlockConditionValues } from '@/lib/workflows/sub-block-conditions' +import type { SubBlockConfig } from '@/blocks/types' +import { useTagSelection } from '@/hooks/use-tag-selection' +import { toPortfolioValueObject } from '@/providers/trading/portfolio-identity' import { createEmptyListingSelectorInstance, useListingSelectorStore, } from '@/stores/market/selector/store' +import { useDependsOnGate } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/hooks/use-depends-on-gate' import { useSubBlockValue } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/hooks/use-sub-block-value' -import { useTagSelection } from '@/hooks/use-tag-selection' +import { useOptionalWorkflowRoute } from '@/widgets/widgets/editor_workflow/context/workflow-route-context' interface ListingSelectorInputProps { blockId: string @@ -23,8 +27,8 @@ interface ListingSelectorInputProps { disabled?: boolean config?: SubBlockConfig providerType?: 'market' | 'trading' - providerFieldId?: string - providerValueOverride?: string | null + tradingProviderFieldId?: string + contextValues?: Record } function isVariableListingInput(value: string): boolean { @@ -33,6 +37,46 @@ function isVariableListingInput(value: string): boolean { return trimmed.startsWith('<') } +const resolveListingProviderId = (value: unknown): string | undefined => { + if (typeof value === 'string') { + const trimmed = value.trim() + return trimmed || undefined + } + + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined + } + + return toPortfolioValueObject(value)?.providerId +} + +const dependsOnIncludes = (dependsOn: SubBlockConfig['dependsOn'], field: string): boolean => { + if (Array.isArray(dependsOn)) return dependsOn.includes(field) + return Boolean(dependsOn?.all?.includes(field) || dependsOn?.any?.includes(field)) +} + +const readContextValue = (contextValues: Record | undefined, field: string) => { + if (!contextValues || !Object.hasOwn(contextValues, field)) return undefined + return contextValues[field] +} + +const toFetchedListingOption = (option: { value?: unknown }) => { + const identity = toListingValueObject(option.value) + if ( + !identity || + !option.value || + typeof option.value !== 'object' || + Array.isArray(option.value) + ) { + return null + } + + return option.value as ListingOption +} + +const isListingOption = (value: ListingOption | null): value is ListingOption => Boolean(value) +const EMPTY_LISTING_OPTIONS: ListingOption[] = [] + export function ListingSelectorInput({ blockId, subBlockId, @@ -41,20 +85,59 @@ export function ListingSelectorInput({ disabled = false, config, providerType, - providerFieldId, - providerValueOverride, + tradingProviderFieldId, + contextValues, }: ListingSelectorInputProps) { const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId) - const providerField = providerFieldId ?? config?.providerFieldId ?? 'provider' - const [providerValueFromStore] = useSubBlockValue(blockId, providerField) - const providerValue = providerValueOverride ?? providerValueFromStore + const routeContext = useOptionalWorkflowRoute() + const resolvedProviderType = providerType ?? config?.providerType ?? 'market' + const configuredTradingProviderField = tradingProviderFieldId ?? config?.tradingProviderFieldId + const providerField = 'provider' + const hasLocalProviderSource = + !configuredTradingProviderField && dependsOnIncludes(config?.dependsOn, providerField) + const [providerValueFromStore] = useSubBlockValue(blockId, providerField) + const [tradingProviderValueFromStore] = useSubBlockValue( + blockId, + configuredTradingProviderField ?? providerField + ) + const providerValue = hasLocalProviderSource + ? (readContextValue(contextValues, providerField) ?? providerValueFromStore) + : undefined + const tradingProviderValue = configuredTradingProviderField + ? (readContextValue(contextValues, configuredTradingProviderField) ?? + tradingProviderValueFromStore) + : undefined + const primaryProviderId = resolveListingProviderId(providerValue) + const marketProviderId = resolvedProviderType === 'market' ? primaryProviderId : undefined + const tradingProviderId = + resolveListingProviderId(tradingProviderValue) ?? + (resolvedProviderType === 'trading' ? primaryProviderId : undefined) + const providerId = resolvedProviderType === 'trading' ? tradingProviderId : marketProviderId const ensureInstance = useListingSelectorStore((state) => state.ensureInstance) const updateInstance = useListingSelectorStore((state) => state.updateInstance) const instance = useListingSelectorStore((state) => state.instances[`${blockId}-${subBlockId}`]) const emitTagSelection = useTagSelection(blockId, subBlockId) + const resolvedConfig: SubBlockConfig = config ?? { + id: subBlockId, + title: 'Listing', + type: 'market-selector', + } + const { finalDisabled: dependsOnDisabled } = useDependsOnGate(blockId, resolvedConfig, { + disabled, + contextValues, + }) + const fetchOptions = config?.fetchOptions + const usesFetchedListingOptions = + Boolean(fetchOptions) && + evaluateSubBlockConditionValues(config?.fetchOptionsCondition, contextValues ?? {}) + const finalDisabled = dependsOnDisabled + const [fetchedListingOptions, setFetchedListingOptions] = useState(null) + const [isLoadingListingOptions, setIsLoadingListingOptions] = useState(false) + const [listingOptionsError, setListingOptionsError] = useState() const instanceId = useMemo(() => `${blockId}-${subBlockId}`, [blockId, subBlockId]) - const previousProviderRef = useRef(undefined) + const contextValuesSignature = useMemo(() => JSON.stringify(contextValues ?? {}), [contextValues]) + const previousProviderRef = useRef(undefined) useEffect(() => { ensureInstance(instanceId) @@ -77,6 +160,81 @@ export function ListingSelectorInput({ })() : null + useEffect(() => { + if (!usesFetchedListingOptions || finalDisabled || !fetchOptions) { + setFetchedListingOptions(null) + setIsLoadingListingOptions(false) + setListingOptionsError(undefined) + return + } + + let cancelled = false + setFetchedListingOptions(null) + setIsLoadingListingOptions(true) + setListingOptionsError(undefined) + + fetchOptions(blockId, subBlockId, { + channelId: routeContext?.channelId ?? '', + workflowId: routeContext?.workflowId ?? null, + workspaceId: routeContext?.workspaceId, + contextValues, + }) + .then((options) => { + if (cancelled) return + setFetchedListingOptions(options.map(toFetchedListingOption).filter(isListingOption)) + }) + .catch((error) => { + if (cancelled) return + setFetchedListingOptions(null) + setListingOptionsError(error instanceof Error ? error.message : 'Failed to load listings') + }) + .finally(() => { + if (!cancelled) setIsLoadingListingOptions(false) + }) + + return () => { + cancelled = true + } + }, [ + usesFetchedListingOptions, + finalDisabled, + fetchOptions, + blockId, + subBlockId, + routeContext?.channelId, + routeContext?.workflowId, + routeContext?.workspaceId, + contextValuesSignature, + ]) + + useEffect(() => { + if (!usesFetchedListingOptions || !fetchedListingOptions || !currentListingIdentity) return + if (typeof currentValue === 'string' && isVariableListingInput(currentValue)) return + if ( + fetchedListingOptions.some((listing) => + areListingIdentitiesEqual(listing, currentListingIdentity) + ) + ) { + return + } + + updateInstance(instanceId, { query: '', selectedListingValue: null, selectedListing: null }) + if (onChange) { + onChange(null) + } else { + setStoreValue(null) + } + }, [ + usesFetchedListingOptions, + currentListingIdentity, + currentValue, + fetchedListingOptions, + instanceId, + updateInstance, + onChange, + setStoreValue, + ]) + useEffect(() => { if (typeof currentValue === 'string' && isVariableListingInput(currentValue)) { if ( @@ -93,11 +251,7 @@ export function ListingSelectorInput({ return } - if ( - !onChange && - typeof currentValue === 'string' && - !isVariableListingInput(currentValue) - ) { + if (!onChange && typeof currentValue === 'string' && !isVariableListingInput(currentValue)) { setStoreValue(null) return } @@ -142,17 +296,18 @@ export function ListingSelectorInput({ ]) useEffect(() => { - if (disabled) return - const normalizedProvider = providerValue ?? undefined + if (finalDisabled) return + const normalizedProvider = providerId + const providerSignature = [providerId, marketProviderId, tradingProviderId].join(':') const prevProvider = previousProviderRef.current const hasPreviousProvider = previousProviderRef.current !== undefined const storedProvider = safeInstance.providerId const providerMismatch = storedProvider !== normalizedProvider - const providerChanged = hasPreviousProvider && prevProvider !== normalizedProvider + const providerChanged = hasPreviousProvider && prevProvider !== providerSignature const needsProviderSync = providerMismatch if (!providerChanged && !needsProviderSync) { - previousProviderRef.current = normalizedProvider + previousProviderRef.current = providerSignature return } @@ -175,13 +330,15 @@ export function ListingSelectorInput({ updateInstance(instanceId, { providerId: normalizedProvider }) } - previousProviderRef.current = normalizedProvider + previousProviderRef.current = providerSignature }, [ - providerValue, + providerId, + marketProviderId, + tradingProviderId, safeInstance.providerId, instanceId, updateInstance, - disabled, + finalDisabled, onChange, setStoreValue, ]) @@ -190,11 +347,18 @@ export function ListingSelectorInput({ { - if (disabled) return + if (finalDisabled) return const normalizedListing = toListingValue(listing) if (onChange) { onChange(normalizedListing ?? null) @@ -203,7 +367,7 @@ export function ListingSelectorInput({ setStoreValue(normalizedListing ?? null) }} onListingValueChange={(value) => { - if (disabled) return + if (finalDisabled) return if (onChange) { onChange(value ?? null) return @@ -211,7 +375,7 @@ export function ListingSelectorInput({ setStoreValue(value ?? null) }} onListingTagSelect={(value) => { - if (disabled) return + if (finalDisabled) return emitTagSelection(value) }} /> diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/tool-input/components/tools/sub-block-renderer.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/tool-input/components/tools/sub-block-renderer.tsx index 12bd88eda..8a2ffa0b9 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/tool-input/components/tools/sub-block-renderer.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/tool-input/components/tools/sub-block-renderer.tsx @@ -14,6 +14,7 @@ interface ToolSubBlockRendererProps { subBlock: SubBlockConfig effectiveParamId: string toolParams: Record | undefined + contextValues: Record | undefined onParamChange: (toolIndex: number, paramId: string, value: any) => void isConnecting: boolean disabled: boolean @@ -92,6 +93,7 @@ function StoredToolSubBlockRenderer({ subBlock, effectiveParamId, toolParams, + contextValues, onParamChange, isConnecting, disabled, @@ -144,7 +146,7 @@ function StoredToolSubBlockRenderer({ config={config} isConnecting={isConnecting} disabled={disabled} - contextValues={toolParams} + contextValues={contextValues} /> ) } diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx index 5549dffa6..0c343d2d9 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx @@ -599,8 +599,8 @@ export function ToolInput({ blockId, subBlockId, isConnecting, disabled = false const condition = param.uiComponent.condition const currentValues: Record = { - operation: tool.operation, ...tool.params, + operation: tool.operation, } return evaluateSubBlockConditionValues(condition, currentValues) @@ -611,11 +611,10 @@ export function ToolInput({ blockId, subBlockId, isConnecting, disabled = false param: ToolParameterConfig, toolIndex: number, currentToolParams: Record, + currentToolContext: Record, toolId: string ) => { const uiComponent = param.uiComponent - const toSyntheticParamId = (paramId: string) => `${subBlockId}-tool-${toolIndex}-${paramId}` - const providerFieldId = toSyntheticParamId(uiComponent?.providerFieldId || 'provider') const providerType = uiComponent?.providerType || (toolId?.startsWith('trading_') ? 'trading' : 'market') const subBlock: SubBlockConfig = { @@ -628,13 +627,14 @@ export function ToolInput({ blockId, subBlockId, isConnecting, disabled = false description: uiComponent?.description, tooltip: uiComponent?.tooltip, required: param.required, + fetchOptionsCondition: uiComponent?.fetchOptionsCondition, password: uiComponent?.password || isPasswordParameter(param.id), inputType: uiComponent?.inputType, provider: uiComponent?.provider, serviceId: uiComponent?.serviceId, requiredScopes: uiComponent?.requiredScopes, providerType, - providerFieldId, + tradingProviderFieldId: uiComponent?.tradingProviderFieldId, enableSearch: uiComponent?.enableSearch, searchPlaceholder: uiComponent?.searchPlaceholder, mimeType: uiComponent?.mimeType, @@ -666,7 +666,7 @@ export function ToolInput({ blockId, subBlockId, isConnecting, disabled = false ? async (blockId, subBlockId, context) => uiComponent.fetchOptions?.(blockId, subBlockId, { ...context, - contextValues: currentToolParams, + contextValues: currentToolContext, } as any) ?? [] : undefined, } @@ -679,6 +679,7 @@ export function ToolInput({ blockId, subBlockId, isConnecting, disabled = false subBlock={subBlock} effectiveParamId={param.id} toolParams={currentToolParams} + contextValues={currentToolContext} onParamChange={handleParamChange} isConnecting={isConnecting} disabled={disabled} @@ -946,7 +947,7 @@ export function ToolInput({ blockId, subBlockId, isConnecting, disabled = false options={operationOptions} placeholder='Select operation' useStore={false} - valueOverride={tool.operation || operationOptions[0].id} + valueOverride={tool.operation} onChange={(value) => handleOperationChange(toolIndex, value)} disabled={disabled} /> @@ -979,9 +980,10 @@ export function ToolInput({ blockId, subBlockId, isConnecting, disabled = false {getRenderableToolParameters(displayParams) .filter((param) => evaluateParameterCondition(param, tool)) .map((param) => { - const currentToolParams = { + const currentToolParams = tool.params + const currentToolContext = { ...tool.params, - ...(tool.operation ? { operation: tool.operation } : {}), + operation: tool.operation, } return (
@@ -990,6 +992,7 @@ export function ToolInput({ blockId, subBlockId, isConnecting, disabled = false param, toolIndex, currentToolParams, + currentToolContext, currentToolId )}
diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/sub-block.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/sub-block.tsx index ece71b02c..df666ceb9 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/sub-block.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/sub-block.tsx @@ -409,6 +409,7 @@ export const SubBlock = memo( subBlockId={config.id} disabled={isDisabled} config={config} + contextValues={contextValues} /> ) case 'order-id-selector': diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor-app.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor-app.tsx index 5cd632a6c..20cf70f5b 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor-app.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor-app.tsx @@ -1,16 +1,12 @@ 'use client' import { useSession } from '@/lib/auth-client' -import Providers from '@/app/workspace/[workspaceId]/providers/providers' -import { WorkflowRouteProvider } from '@/widgets/widgets/editor_workflow/context/workflow-route-context' -import { - type WorkflowCanvasUIConfig, -} from '@/widgets/widgets/editor_workflow/components/workflow-editor/workflow-canvas' -import { - DEFAULT_WORKFLOW_CHANNEL_ID, -} from '@/stores/workflows/workflow/types' import { WorkflowSessionProvider } from '@/lib/yjs/workflow-session-host' +import Providers from '@/app/workspace/[workspaceId]/providers/providers' +import { DEFAULT_WORKFLOW_CHANNEL_ID } from '@/stores/workflows/workflow/types' import Workflow from '@/widgets/widgets/editor_workflow/components/workflow' +import type { WorkflowCanvasUIConfig } from '@/widgets/widgets/editor_workflow/components/workflow-editor/workflow-canvas' +import { WorkflowRouteProvider } from '@/widgets/widgets/editor_workflow/context/workflow-route-context' interface WorkflowEditorAppProps { workspaceId: string @@ -35,20 +31,16 @@ const WorkflowEditorApp = ({ const user = session.data?.user ? { - id: session.data.user.id, - name: session.data.user.name ?? undefined, - email: session.data.user.email, - } + id: session.data.user.id, + name: session.data.user.name ?? undefined, + email: session.data.user.email, + } : undefined const workflowRenderKey = `${channelId}:${workflowId}` return ( - + +

{title}

-

+

{value} -

+
) } +function SummaryListingRow({ + title, + value, + labelClassName, + valueClassName, +}: { + title: string + value: unknown + labelClassName?: string + valueClassName?: string +}) { + const identity = useMemo(() => toListingValueObject(value), [value]) + const valueListing = + value && typeof value === 'object' && !Array.isArray(value) ? (value as ListingOption) : null + const [resolvedListing, setResolvedListing] = useState(null) + + useEffect(() => { + setResolvedListing(null) + if (!identity) return + + let cancelled = false + requestListingResolution(identity) + .then((resolved) => { + if (cancelled) return + setResolvedListing(resolved ? buildListingDisplayOption(identity, resolved) : null) + }) + .catch(() => {}) + + return () => { + cancelled = true + } + }, [identity]) + + if (!identity) { + return ( + + ) + } + + const displayListing = buildListingDisplayOption(identity, resolvedListing ?? valueListing) + const displayTitle = getListingDisplaySymbol(displayListing) + + return ( + } + valueTitle={displayTitle || getListingIdentityKey(identity)} + labelClassName={labelClassName} + valueClassName={valueClassName} + /> + ) +} + export function SubBlockSummaryRows({ blockId, subBlocks, @@ -354,6 +433,18 @@ export function SubBlockSummaryRows({ ) } + if (!subBlock.password && subBlock.type === 'market-selector') { + return ( + + ) + } + return ( { + return +} + export const workflowEditorWidget: DashboardWidgetDefinition = { key: 'editor_workflow', title: 'Workflow Editor', @@ -236,7 +248,12 @@ export const workflowEditorWidget: DashboardWidgetDefinition = { const toolbarScopeId = readWorkflowToolbarScopeId(widgetKey, panelId) return { - left: , + left: ( + + ), center: ( ({ ListingSelector: ({ instanceId, providerType, + marketProviderId, + tradingProviderId, listingRequired, className, onListingChange, @@ -159,6 +161,8 @@ vi.mock('@/components/listing-selector/selector/combo', () => ({ }: { instanceId: string providerType: string + marketProviderId?: string + tradingProviderId?: string listingRequired?: boolean className?: string onListingChange: (listing: Record) => void @@ -168,6 +172,8 @@ vi.mock('@/components/listing-selector/selector/combo', () => ({ data-testid='listing-selector-surface' data-instance-id={instanceId} data-provider-type={providerType} + data-market-provider-id={marketProviderId ?? ''} + data-trading-provider-id={tradingProviderId ?? ''} data-listing-required={listingRequired ? 'true' : 'false'} data-class-name={className ?? ''} > @@ -349,6 +355,8 @@ describe('QuickOrderWidgetBody', () => { ) expect(selector?.dataset.instanceId).toBe('quick-order-panel-1-quick_order') expect(selector?.dataset.providerType).toBe('trading') + expect(selector?.dataset.marketProviderId).toBe('') + expect(selector?.dataset.tradingProviderId).toBe('alpaca') expect( useListingSelectorStore.getState().instances['quick-order-panel-1-quick_order']?.providerId ).toBe('alpaca') @@ -424,6 +432,11 @@ describe('QuickOrderWidgetBody', () => { side: 'buy', }) + expect( + container.querySelector('[data-testid="listing-selector-surface"]')?.dataset + .marketProviderId + ).toBe('finnhub') + await act(async () => { container.querySelector('[data-testid="listing-selector"]')?.click() }) diff --git a/apps/tradinggoose/widgets/widgets/quick_order/components/body.tsx b/apps/tradinggoose/widgets/widgets/quick_order/components/body.tsx index b2ac3111e..ee592f07d 100644 --- a/apps/tradinggoose/widgets/widgets/quick_order/components/body.tsx +++ b/apps/tradinggoose/widgets/widgets/quick_order/components/body.tsx @@ -669,6 +669,8 @@ export function QuickOrderWidgetBody({ { diff --git a/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-header-controls.ui.test.tsx b/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-header-controls.ui.test.tsx index 74bf8c4f6..3b3498c0c 100644 --- a/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-header-controls.ui.test.tsx +++ b/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-header-controls.ui.test.tsx @@ -36,8 +36,8 @@ vi.mock('@/widgets/utils/watchlist-params', () => ({ emitWatchlistParamsChange: vi.fn(), })) -vi.mock('@/widgets/widgets/components/listing-selector', () => ({ - ListingSelector: (props: { +vi.mock('@/components/listing-selector/selector/input', () => ({ + ListingSearchInput: (props: { disabled?: boolean onListingChange?: (listing: { listing_id: string diff --git a/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-listing-selector.test.tsx b/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-listing-selector.test.tsx deleted file mode 100644 index 371484e72..000000000 --- a/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-listing-selector.test.tsx +++ /dev/null @@ -1,97 +0,0 @@ -/** - * @vitest-environment jsdom - */ - -import { act } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - createEmptyListingSelectorInstance, - useListingSelectorStore, -} from '@/stores/market/selector/store' -import { WatchlistListingSelector } from '@/widgets/widgets/watchlist/components/watchlist-listing-selector' - -const reactActEnvironment = globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT?: boolean -} - -vi.mock('@/hooks/workflow/use-accessible-reference-prefixes', () => ({ - useAccessibleReferencePrefixes: () => undefined, -})) - -vi.mock('@/components/listing-selector/selector/use-listing-search', () => ({ - useMarketListingSearch: vi.fn(), -})) - -describe('WatchlistListingSelector', () => { - let container: HTMLDivElement - let root: Root - - beforeEach(() => { - vi.useFakeTimers() - reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) - useListingSelectorStore.setState({ instances: {} }) - }) - - afterEach(() => { - act(() => { - root.unmount() - }) - container.remove() - useListingSelectorStore.setState({ instances: {} }) - reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = false - vi.useRealTimers() - }) - - it('activates populated selectors on mount so the current listing is editable and the dropdown opens', async () => { - useListingSelectorStore.setState({ - instances: { - 'test-selector': createEmptyListingSelectorInstance({ - providerId: 'alpaca', - query: '', - selectedListingValue: { - listing_id: 'BTCUSD', - base_id: 'BTC', - quote_id: 'USD', - listing_type: 'default', - }, - selectedListing: { - listing_id: 'BTCUSD', - base_id: 'BTC', - quote_id: 'USD', - listing_type: 'default', - base: 'BTC', - quote: 'USD', - name: 'Bitcoin', - }, - }), - }, - }) - - await act(async () => { - root.render( - - ) - }) - - await act(async () => { - vi.runAllTimers() - }) - - const input = container.querySelector('input') - const dropdown = document.body.querySelector('[data-market-selector-id="test-selector"]') - - expect(input).toBeTruthy() - expect(input?.value).toBe('BTC/USD') - expect(document.activeElement).toBe(input) - expect(dropdown).toBeTruthy() - expect(document.body.textContent).toContain('No listings found.') - }) -}) diff --git a/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-listing-selector.tsx b/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-listing-selector.tsx deleted file mode 100644 index 39a6f5bcb..000000000 --- a/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-listing-selector.tsx +++ /dev/null @@ -1,20 +0,0 @@ -'use client' - -import type { ListingOption } from '@/lib/listing/identity' -import { ListingSelector } from '@/widgets/widgets/components/listing-selector' - -export interface WatchlistListingSelectorProps { - instanceId: string - blockId?: string - disabled?: boolean - className?: string - providerType?: 'market' | 'trading' - activateOnMount?: boolean - onListingChange?: (listing: ListingOption | null) => void - onListingValueChange?: (value: string | null) => void - onListingTagSelect?: (value: string) => void -} - -export function WatchlistListingSelector(props: WatchlistListingSelectorProps) { - return -} diff --git a/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-table.tsx b/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-table.tsx index 3c52f54e0..3a0f18f1e 100644 --- a/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-table.tsx +++ b/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-table.tsx @@ -26,6 +26,7 @@ import { import { sortableKeyboardCoordinates } from '@dnd-kit/sortable' import { ChevronRight, Pencil, Trash2, X } from 'lucide-react' import { getListingPrimary, MarketListingRow } from '@/components/listing-selector/listing/row' +import { ListingSearchInput } from '@/components/listing-selector/selector/input' import { requestListingResolution } from '@/components/listing-selector/selector/resolve-request' import { AlertDialog, @@ -41,6 +42,7 @@ import { Button } from '@/components/ui/button' import { Sortable, SortableContent, SortableItem } from '@/components/ui/sortable' import { areListingIdentitiesEqual, + buildListingDisplayOption, type ListingIdentity, type ListingOption, toListingValue, @@ -53,7 +55,6 @@ import type { WatchlistSectionItem, } from '@/lib/watchlists/types' import { useListingSelectorStore } from '@/stores/market/selector/store' -import { WatchlistListingSelector } from '@/widgets/widgets/watchlist/components/watchlist-listing-selector' import { createWatchlistListingSortableId, createWatchlistSectionSortableId, @@ -119,23 +120,6 @@ const formatPrice = (value: number | null) => (value == null ? '-' : priceFormat const formatPercent = (value: number | null) => value == null ? '-' : `${percentFormatter.format(value)}%` -const buildListingOption = ( - listing: ListingIdentity, - resolved?: ListingOption | null -): ListingOption => ({ - ...listing, - ...resolved, - base: - resolved?.base?.trim() || - (listing.listing_type === 'default' ? listing.listing_id : listing.base_id), - quote: resolved?.quote?.trim() || (listing.listing_type === 'default' ? null : listing.quote_id), - name: - resolved?.name?.trim() || - (listing.listing_type === 'default' - ? listing.listing_id - : `${listing.base_id}/${listing.quote_id}`), -}) - const stopSortableActivation = ( event: | ReactMouseEvent @@ -302,7 +286,7 @@ export const WatchlistTable = ({ isLoading: false, error: undefined, selectedListingValue: row.item.listing, - selectedListing: buildListingOption( + selectedListing: buildListingDisplayOption( row.listing, areListingIdentitiesEqual(resolvedByItemId[row.itemId]?.identity, row.listing) ? resolvedByItemId[row.itemId]?.resolved @@ -539,8 +523,9 @@ export const WatchlistTable = ({ return (
- ({ getListingPrimary: (listing: { name?: string; listing_id?: string }) => @@ -41,8 +41,8 @@ vi.mock('@/components/listing-selector/listing/row', () => ({ ), })) -vi.mock('@/widgets/widgets/watchlist/components/watchlist-listing-selector', () => ({ - WatchlistListingSelector: ({ +vi.mock('@/components/listing-selector/selector/input', () => ({ + ListingSearchInput: ({ instanceId, activateOnMount, onListingChange, @@ -57,15 +57,15 @@ vi.mock('@/widgets/widgets/watchlist/components/watchlist-listing-selector', () name?: string }) => void }) => { - mockWatchlistListingSelectorRender({ instanceId, activateOnMount }) + mockStockSelectorRender({ instanceId, activateOnMount }) return ( -
-
) @@ -569,17 +569,17 @@ describe('WatchlistTable section interactions', () => { }) const selector = container.querySelector( - '[data-testid="watchlist-listing-selector-watchlist-listing-editor-listing-1"]' + '[data-testid="stock-selector-watchlist-listing-editor-listing-1"]' ) expect(selector).toBeTruthy() - expect(mockWatchlistListingSelectorRender).toHaveBeenLastCalledWith({ + expect(mockStockSelectorRender).toHaveBeenLastCalledWith({ instanceId: 'watchlist-listing-editor-listing-1', activateOnMount: true, }) const selectButton = container.querySelector( - '[data-testid="watchlist-listing-selector-select-watchlist-listing-editor-listing-1"]' + '[data-testid="stock-selector-select-watchlist-listing-editor-listing-1"]' ) await act(async () => { @@ -628,7 +628,7 @@ describe('WatchlistTable section interactions', () => { }) const selectButton = container.querySelector( - '[data-testid="watchlist-listing-selector-select-watchlist-listing-editor-listing-1"]' + '[data-testid="stock-selector-select-watchlist-listing-editor-listing-1"]' ) await act(async () => { @@ -675,7 +675,9 @@ describe('WatchlistTable section interactions', () => { await Promise.resolve() }) - expect(container.textContent).toContain('Bitcoin') + await vi.waitFor(() => { + expect(container.textContent).toContain('Bitcoin') + }) const updatedWatchlist: WatchlistRecord = { ...watchlist, @@ -705,9 +707,11 @@ describe('WatchlistTable section interactions', () => { await Promise.resolve() }) - expect(mockResolveListing).toHaveBeenCalledTimes(2) - expect(container.textContent).toContain('Apple') - expect(container.textContent).not.toContain('Bitcoin') + await vi.waitFor(() => { + expect(mockResolveListing).toHaveBeenCalledTimes(2) + expect(container.textContent).toContain('Apple') + expect(container.textContent).not.toContain('Bitcoin') + }) }) it('keeps symbol edit mode active for internal clicks and cancels it on outside clicks without saving', async () => { @@ -726,10 +730,10 @@ describe('WatchlistTable section interactions', () => { }) const selector = container.querySelector( - '[data-testid="watchlist-listing-selector-watchlist-listing-editor-listing-1"]' + '[data-testid="stock-selector-watchlist-listing-editor-listing-1"]' ) const focusButton = container.querySelector( - '[data-testid="watchlist-listing-selector-focus-watchlist-listing-editor-listing-1"]' + '[data-testid="stock-selector-focus-watchlist-listing-editor-listing-1"]' ) const editingRow = Array.from(container.querySelectorAll('tr')).find( (row) => @@ -748,9 +752,7 @@ describe('WatchlistTable section interactions', () => { }) expect( - container.querySelector( - '[data-testid="watchlist-listing-selector-watchlist-listing-editor-listing-1"]' - ) + container.querySelector('[data-testid="stock-selector-watchlist-listing-editor-listing-1"]') ).toBeTruthy() expect(onUpdateItemListing).not.toHaveBeenCalled() @@ -759,9 +761,7 @@ describe('WatchlistTable section interactions', () => { }) expect( - container.querySelector( - '[data-testid="watchlist-listing-selector-watchlist-listing-editor-listing-1"]' - ) + container.querySelector('[data-testid="stock-selector-watchlist-listing-editor-listing-1"]') ).toBeNull() expect(onUpdateItemListing).not.toHaveBeenCalled() }) diff --git a/changelog/May-26-2026.md b/changelog/May-26-2026.md new file mode 100644 index 000000000..f760e8d6a --- /dev/null +++ b/changelog/May-26-2026.md @@ -0,0 +1,81 @@ +# May-26-2026 + +## feat/watchlist-tool @ 05472356 vs upstream/staging + +### Summary +- Adds a workflow Watchlist block and four registered watchlist tools for reading watchlists, reading ordered items, adding listings, and removing listings through the canonical watchlist API routes. +- Unifies listing selection around `ListingSearchInput`, provider-aware listing identity filters, and `listingIdentity` tool/schema contracts so widgets, workflow blocks, tool inputs, and watchlists reuse the same selector path. +- Extends watchlist operations and routes to remove listings by structured listing identity, while keeping section/item management and workspace permission checks on existing watchlist routes. +- Refines trading order and holdings blocks so broker provider/account options are loaded from OAuth availability and trading listings are filtered by both selected broker capability and optional market-data provider scope. + +### Branch Scope +- Compared `b724eb1ccde29c197b6ecd53b64caa2ca43b7832..05472356c9dfe28bcb7730dbb5621ede4fa29dff`, where `b724eb1ccde29c197b6ecd53b64caa2ca43b7832` is both the merge base and current `upstream/staging`. +- Ran `git fetch upstream staging` before comparing. This entry intentionally uses `upstream/staging`, not the template default `origin/staging`, because the user requested the upstream base. +- `git status --short` was clean before this changelog edit, so no uncommitted feature work was included in the reviewed diff. The current uncommitted change is this dated changelog file only. +- Main areas touched: `apps/tradinggoose/blocks/blocks/*`, `apps/tradinggoose/blocks/{registry,types,utils}.ts`, `apps/tradinggoose/tools/{registry,params,trading,watchlist}/*`, `apps/tradinggoose/app/api/watchlists/*`, `apps/tradinggoose/lib/{listing,watchlists}/*`, shared listing selector components under `apps/tradinggoose/components/listing-selector/*`, editor workflow sub-block/tool rendering, watchlist/data-chart/quick-order widgets, monitor management listing input, and focused tests for those paths. + +### Key Changes +- `apps/tradinggoose/blocks/blocks/watchlist.ts` introduces `WatchlistBlock` with `readLists`, `readListItems`, `addListing`, and `removeListing` operations. It loads watchlist names from `/api/watchlists?workspaceId=...`, loads remove candidates from the selected watchlist, resolves listing display metadata through `resolveListingIdentities()`, and exposes `watchlistId` plus `listing` as user/LLM-visible inputs. +- `apps/tradinggoose/tools/watchlist/index.ts` adds `WATCHLIST_TOOL_IDS`, `watchlistReadListsTool`, `watchlistReadListItemsTool`, `watchlistAddListingTool`, and `watchlistRemoveListingTool`. Read tools use `/api/watchlists`; mutation tools post to `/api/watchlists/{watchlistId}/items` with workspace context, `action`, and structured listing identity payloads. +- `apps/tradinggoose/tools/registry.ts` registers the four watchlist tools, and `apps/tradinggoose/blocks/registry.ts` registers `watchlist: WatchlistBlock`, making the block available as a first-class tool block. +- `apps/tradinggoose/lib/listing/identity.ts` adds the canonical `LISTING_IDENTITY_VALUE_TYPE`, `LISTING_IDENTITY_JSON_SCHEMA`, `ListingIdentity`, `ListingResolved`, `ListingOption`, `buildListingDisplayOption()`, `toListingValue()`, `toListingValueObject()`, `parseListingIdentityValueStrict()`, `areListingIdentitiesEqual()`, and `getListingIdentityKey()` contract for default, crypto, and currency listings. +- `apps/tradinggoose/tools/params.ts` maps `listingIdentity` tool params to the JSON schema from `LISTING_IDENTITY_JSON_SCHEMA`, carries `fetchOptionsCondition`, and copies `tradingProviderFieldId` into `uiComponent` metadata so tool inputs can render the correct market selector and dependent option loaders. +- `apps/tradinggoose/lib/watchlists/operations.ts` adds `removeListingFromWatchlist()` and updates duplicate/import logic to compare listings through `toListingValueObject()`, `areListingIdentitiesEqual()`, and `getListingIdentityKey()` instead of relying on item ids or raw object shape. +- `apps/tradinggoose/app/api/watchlists/route.ts` and `apps/tradinggoose/app/api/watchlists/[watchlistId]/items/route.ts` use `checkSessionOrInternalAuth()` plus workspace permissions. The items route now accepts `removeListing` and routes it to `removeListingFromWatchlist()` after validating a structured listing payload. +- `apps/tradinggoose/components/listing-selector/selector/input.tsx` renames the old selector surface to `ListingSearchInput` and adds `variant`, `marketProviderId`, `tradingProviderId`, `activateOnMount`, and candidate-list props. It handles variable tags, stale hydration guards, portal-positioned dropdowns, header rendering, rank updates, and selected-listing display hydration. +- `apps/tradinggoose/components/listing-selector/selector/use-listing-search.ts`, `use-provider-config.ts`, and `search-request.ts` combine market-provider and trading-provider availability into one `ProviderSearchConfig`, filter blank searches unless provider criteria exist, debounce text searches, support scoped candidate listings, and prevent explicit asset prefixes from bypassing provider capability filters. +- `apps/tradinggoose/components/listing-selector/selector/dropdown.tsx` replaces the older wrapper component with `ListingSelectorDropdownContent`, a content-only dropdown that accepts custom row rendering and scroll event hooks. `apps/tradinggoose/components/listing-selector/selector/combo.tsx` becomes the labeled wrapper around `ListingSearchInput`. +- `apps/tradinggoose/components/listing-selector/listing/row.tsx` exports shared row/display helpers including `getListingPrimary()`, `getListingDisplaySymbol()`, `getListingSecondary()`, `getListingFallback()`, `getListingDisplayFallback()`, `hasListingDisplayDetails()`, `getFlagData()`, `ListingDisplayRow`, and `MarketListingRow`. +- `apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/listing-selector/listing-selector.tsx` adapts workflow `market-selector` sub-blocks to the shared selector. It resolves local market provider state, optional `tradingProviderFieldId`, fetched candidate listings, `fetchOptionsCondition`, variable tags, and provider-change invalidation. +- `apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx` and `.../components/tools/sub-block-renderer.tsx` pass operation-aware context separately from persisted tool params, preserve `fetchOptionsCondition`, and propagate `tradingProviderFieldId` so multi-operation tools like Watchlist can render remove-listing candidate options correctly. +- `apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-render/sub-block-summary-rows.tsx` renders `market-selector` summaries through `SummaryListingRow`, resolving listing identities with `requestListingResolution()` and showing `ListingDisplayRow` instead of raw object previews. +- `apps/tradinggoose/blocks/utils.ts`, `apps/tradinggoose/blocks/blocks/trading_action.ts`, and `apps/tradinggoose/blocks/blocks/trading_holdings.ts` add `fetchTradingProviderOptionsByKind()` and shift trading provider dropdowns to OAuth-availability-backed `fetchOptions`. Trading action listings now use `providerType: 'market'`, `tradingProviderFieldId: 'provider'`, and provider-dependent listing/order-type invalidation. +- `apps/tradinggoose/tools/trading/action.ts` and `apps/tradinggoose/tools/trading/types.ts` add a user-only `provider` parameter to `TradingActionParams` so the UI can scope account and listing availability while `buildOrderRoutePayload()` continues to submit canonical route fields. +- Watchlist, data-chart, quick-order, and monitor UI now import `ListingSearchInput` or `ListingSelector` from `apps/tradinggoose/components/listing-selector/selector/*`. `apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-table.tsx` uses the shared input for inline listing edits, and `apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-header-controls.tsx` reuses `DataChartListingSelector` for the header add-listing control. +- Focused tests were added or updated in `apps/tradinggoose/blocks/blocks/watchlist.test.ts`, `apps/tradinggoose/tools/watchlist/index.test.ts`, `apps/tradinggoose/components/listing-selector/selector/{input,use-listing-search}.test.tsx`, `apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/listing-selector/listing-selector.test.tsx`, watchlist route tests, trading order contract tests, quick-order tests, and watchlist table tests. + +### Design Decisions +- Watchlist automation is a block plus registered tools, not a separate bespoke executor path. The block selects one of the `WATCHLIST_TOOL_IDS`, and the tools call the same `/api/watchlists` and `/api/watchlists/[watchlistId]/items` routes used by the UI. +- Listing identity is a structured `listingIdentity` value. Blocks, tools, APIs, and widgets should pass `{ listing_id, base_id, quote_id, listing_type }` rather than raw symbols, item ids, or provider-local display strings. +- Remove-listing automation uses listing identity instead of watchlist item id. That lets an LLM or workflow remove the selected listing from a watchlist without first exposing internal item row ids. +- The listing selector is shared at `apps/tradinggoose/components/listing-selector/selector/input.tsx`. Header controls, field controls, workflow sub-blocks, watchlist inline editing, monitor management, data chart, and quick order use the same store-backed selector behavior instead of local wrapper selectors. +- Provider filtering separates market-data scope from trading capability scope. A trading order selector can combine `marketProviderId` and `tradingProviderId`, while watchlist/data-chart selectors can use only market-provider scope. +- Tool input rendering now separates operation/context values from persisted tool params so dependent option loaders can see `operation` and provider selections without accidentally storing synthetic context as tool params. +- Trading provider dropdowns load available connected services through `fetchTradingProviderOptionsByKind()` instead of static provider lists. This keeps trading account and listing fields disabled until an actually available broker provider is selected. + +### Shared Contracts and Helpers to Reuse +- Reuse `WatchlistBlock` from `apps/tradinggoose/blocks/blocks/watchlist.ts` for workflow watchlist automation. Add future watchlist operations by extending its operation options and `WATCHLIST_TOOL_IDS` together. +- Reuse `WATCHLIST_TOOL_IDS` and the exported watchlist tool configs from `apps/tradinggoose/tools/watchlist/index.ts` instead of hardcoding tool ids or duplicating watchlist route request builders. +- Reuse `LISTING_IDENTITY_VALUE_TYPE`, `LISTING_IDENTITY_JSON_SCHEMA`, `ListingIdentity`, `ListingOption`, `toListingValue()`, `toListingValueObject()`, `parseListingIdentityValueStrict()`, `areListingIdentitiesEqual()`, `getListingIdentityKey()`, and `buildListingDisplayOption()` from `apps/tradinggoose/lib/listing/identity.ts` for listing persistence, comparison, tool schemas, and UI display. +- Reuse `removeListingFromWatchlist()` from `apps/tradinggoose/lib/watchlists/operations.ts` when callers have a listing identity and need to remove the matching watchlist item. Continue using `removeWatchlistItem()` only when an internal item id is intentionally available. +- Reuse `ListingSearchInput`, `ListingSelector`, `ListingSelectorDropdownContent`, `useMarketListingSearch()`, `buildMarketSearchRequest()`, `combineProviderSearchConfigs()`, and the row helpers under `apps/tradinggoose/components/listing-selector/*` for listing search and display. +- Reuse `fetchTradingProviderOptionsByKind()` and `fetchTradingPortfolioIdentityOptions()` from `apps/tradinggoose/blocks/utils.ts` for trading provider/account dropdowns that need connected-provider availability and selected-provider account loading. +- Reuse `SubBlockConfig.tradingProviderFieldId` and `fetchOptionsCondition` from `apps/tradinggoose/blocks/types.ts` when a market selector or option loader depends on another sub-block. `providerFieldId` is no longer the contract. +- Reuse `SummaryListingRow` behavior through `SubBlockSummaryRows` in `apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-render/sub-block-summary-rows.tsx` for read-only listing summaries. +- Reuse the operation-aware tool input context flow in `apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/tool-input/*` for any future multi-operation tool with dependent sub-block option loaders. + +### Removed or Replaced Items +- `apps/tradinggoose/widgets/widgets/components/listing-selector.tsx` and `apps/tradinggoose/widgets/widgets/components/listing-selector.test.tsx` were deleted. Use `ListingSearchInput` or `ListingSelector` from `apps/tradinggoose/components/listing-selector/selector/*`. +- `apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-listing-selector.tsx` and its test were deleted. Watchlist inline editing now uses the shared `ListingSearchInput` directly. +- The old `StockSelector` export and `StockSelectorProps` naming in `apps/tradinggoose/components/listing-selector/selector/input.tsx` were replaced by `ListingSearchInput` and `ListingSearchInputProps`. Do not restore stock-only naming for a selector that handles default listings, crypto pairs, and currency pairs. +- The old `ListingSelectorDropdown` wrapper was replaced by `ListingSelectorDropdownContent` in `apps/tradinggoose/components/listing-selector/selector/dropdown.tsx`. Positioning and visibility now belong to the caller, while the dropdown content owns row rendering, loading, and highlighted-option scrolling. +- `SubBlockConfig.providerFieldId` was replaced by `tradingProviderFieldId` in `apps/tradinggoose/blocks/types.ts`, `apps/tradinggoose/tools/params.ts`, and workflow listing-selector rendering. Do not reintroduce the ambiguous `providerFieldId` name. +- Direct watchlist removal by item id remains available for UI row actions, but workflow/tool removal should not depend on item ids. Use `removeListingFromWatchlist()` and the `removeListing` route action for listing-identity-driven removal. + +### Future Branch Guardrails +- Do not add watchlist tools outside `apps/tradinggoose/tools/watchlist/index.ts` and `WATCHLIST_TOOL_IDS`. Keep block operation ids, tool ids, route actions, and tests aligned. +- Do not persist or compare listings as display labels, raw symbols, or provider-specific strings. Normalize to `ListingIdentity` with `toListingValueObject()` and compare with `areListingIdentitiesEqual()`. +- Do not create another listing selector wrapper under `apps/tradinggoose/widgets/widgets/*`. Shared listing selection belongs under `apps/tradinggoose/components/listing-selector/selector/*`. +- Do not use item ids for workflow-level remove-listing behavior when a structured listing identity is available. Item ids are UI-row implementation details. +- Do not bypass provider-aware search config by sending raw market search params from components. Use `useMarketListingSearch()` and `buildMarketSearchRequest()` so market and trading provider constraints stay consistent. +- Do not store synthetic tool context such as `operation` as user tool params. Pass it through the tool-input context path and keep persisted params limited to actual tool parameters. +- Do not restore static trading provider dropdown options for order or holdings blocks. Use `fetchTradingProviderOptionsByKind()` so available broker choices match OAuth connection state. +- Do not reintroduce `providerFieldId`; use `tradingProviderFieldId` for trading-provider-dependent market selectors. + +### Validation Notes +- Used the requested `staging-changelog` skill and followed `changelog/TEMPLATE.md`, with the explicit base override from `origin/staging` to `upstream/staging`. +- Reviewed `git status --short`, `git fetch upstream staging`, `git rev-parse upstream/staging`, `git rev-parse feat/watchlist-tool`, `git merge-base upstream/staging feat/watchlist-tool`, `git log --oneline b724eb1ccde29c197b6ecd53b64caa2ca43b7832..05472356c9dfe28bcb7730dbb5621ede4fa29dff`, `git diff --stat`, `git diff --summary`, `git diff --name-status --find-renames`, `git diff --dirstat=files,5`, and `git diff --numstat` for the branch range. +- Inspected new watchlist block/tool files, watchlist API routes/tests, `apps/tradinggoose/lib/watchlists/operations.ts`, and the listing identity contract in `apps/tradinggoose/lib/listing/identity.ts`. +- Inspected shared listing selector files under `apps/tradinggoose/components/listing-selector/*`, deleted selector wrappers from the merge base, workflow sub-block/tool-input rendering, data-chart/quick-order/watchlist/monitor selector callers, and their focused tests. +- Inspected trading action/holdings block changes, `apps/tradinggoose/blocks/utils.ts`, `apps/tradinggoose/tools/{params,registry}.ts`, and `apps/tradinggoose/blocks/blocks/trading_order_contracts.test.ts`. +- No automated test suite was run for this changelog-only update; validation focused on command-backed diff review, source inspection, and template conformance. From 8a60dd62b4bdb42615b0804582850327e7578342 Mon Sep 17 00:00:00 2001 From: Bruzzz BackUp <149516937+BWJ2310-backup@users.noreply.github.com> Date: Fri, 29 May 2026 15:54:59 -0600 Subject: [PATCH 3/5] feat(monitors): add portfolio state monitor support (#129) * feat(monitors): add portfolio monitor support Co-authored-by: Codex * refactor(tradinggoose): move shared widget controls to components Co-authored-by: Codex * feat(tradinggoose): split market and trading provider handling Co-authored-by: Codex * refactor(monitors): normalize provider configs and sources Co-authored-by: Codex * refactor(workflow): inline provider selector sub-blocks Co-authored-by: Codex * feat(monitors): add source-aware monitor handling and runtime locks Co-authored-by: Codex * feat(portfolio): enhance credential handling in portfolio identity functions * feat(monitor): remove referenceData from monitor payload functions * feat(portfolio): enhance service ID resolution and preserve runtime state in portfolio monitor updates * feat(trading): rename holdings surface to portfolio detail Co-authored-by: Codex * fix(monitor): refine config defaults and selector layouts Co-authored-by: Codex * fix(monitor-runtime): ignore deduped pending executions Co-authored-by: Codex * refactor(monitor): simplify monitor card surfaces Co-authored-by: Codex * style(card): adjust card background and shadow for improved aesthetics * feat(monitor-runtime): add updatedAt field and enhance runtime state management * feat(monitor-runtime): enhance execution ID generation and improve error handling for database connection issues * refactor(monitor): simplify MonitorKanbanSection and remove unused props refactor(config): streamline ConfigBoardSection structure and update tests feat(tests): enhance portfolio monitor runtime tests with additional error handling * feat(monitor-config): enhance PortfolioMonitorProviderConfig schema and improve runtime configuration handling * feat(monitor-execution): streamline execution handling by integrating enqueuePendingExecution and removing unused mocks * refactor(trading): remove workspace scoping from connection lookup Co-authored-by: Codex * feat(tradinggoose): disable monitors on permanent dispatch failures Co-authored-by: Codex * feat(monitor): refactor account resolution to include connectionOwnerUserId and streamline service ID handling * feat(monitor-execution): enhance error handling for workflow backlog and update execution logic * feat(indicator-monitor): update source identifier for monitor execution context * feat(portfolio-monitor): generalize monitor management and add portfolio monitor features * feat(monitor): remove TriggerExecutionUnavailableError mock from tests * feat(portfolio-monitor): update condition labels and enhance async handling in data processing --------- Co-authored-by: Codex --- .../landing-indicator-dropdown.tsx | 4 +- .../market-preview/landing-widget-shell.tsx | 2 +- .../market-preview/market-preview.tsx | 2 +- .../workflow-preview/workflow-preview.tsx | 2 +- .../app/api/indicator-monitors/[id]/route.ts | 271 -------- .../app/api/indicator-monitors/route.ts | 169 ----- .../tradinggoose/app/api/logs/export/route.ts | 23 +- apps/tradinggoose/app/api/logs/log-utils.ts | 9 +- apps/tradinggoose/app/api/logs/route.ts | 23 +- .../app/api/monitors/[id]/route.ts | 395 +++++++++++ .../reconcile.ts | 8 +- apps/tradinggoose/app/api/monitors/route.ts | 249 +++++++ .../shared.ts | 220 +++++- .../[orderId]/provider-detail/route.test.ts | 8 +- .../api/providers/trading/order/route.test.ts | 18 +- .../app/api/providers/trading/order/route.ts | 2 +- .../portfolio-identities/route.test.ts | 4 +- .../tools/trading/order-history/route.test.ts | 4 +- .../{holdings => portfolio-detail}/route.ts | 24 +- apps/tradinggoose/app/api/v1/logs/filters.ts | 13 +- apps/tradinggoose/app/api/v1/logs/route.ts | 21 +- .../app/api/webhooks/[id]/route.ts | 17 +- .../app/api/webhooks/[id]/test-url/route.ts | 5 +- apps/tradinggoose/app/api/webhooks/route.ts | 33 +- .../app/api/webhooks/test/[id]/route.ts | 24 +- .../app/api/webhooks/test/route.ts | 5 +- .../app/api/webhooks/trigger/[path]/route.ts | 27 +- .../api/workflows/[id]/deploy/route.test.ts | 11 +- .../app/api/workflows/[id]/deploy/route.ts | 10 +- .../deployments/[version]/activate/route.ts | 8 +- .../[version]/revert/route.test.ts | 16 +- .../deployments/[version]/revert/route.ts | 8 +- apps/tradinggoose/app/globals.css | 2 +- .../components/board/board-state.test.ts | 5 +- .../monitor/components/board/kanban.tsx | 2 +- .../board/monitor-board.interaction.test.tsx | 8 + .../components/board/monitor-board.test.tsx | 16 + .../components/board/monitor-board.tsx | 13 +- .../components/board/monitor-kanban.tsx | 36 +- .../components/config/config-board-state.ts | 10 +- .../components/config/config-card-model.ts | 42 +- .../components/config/config-domain.test.ts | 92 +-- .../monitor/components/config/config-draft.ts | 160 ++++- .../monitor/components/config/config-drop.ts | 10 +- .../components/config/config-filter-values.ts | 2 + .../components/config/config-search.test.tsx | 11 +- .../components/config/config-search.tsx | 2 +- .../config/monitor-config-board.tsx | 9 +- .../monitor/components/data/api.test.ts | 20 + .../monitor/components/data/api.ts | 49 +- .../components/data/execution-ordering.ts | 3 + .../data/use-monitor-execution-summaries.ts | 5 +- .../data/use-monitor-reference-data.test.tsx | 110 +++ .../data/use-monitor-reference-data.ts | 122 +++- .../data/use-monitor-workspace-logs.test.ts | 8 +- .../data/use-monitor-workspace-logs.ts | 36 +- .../management/monitor-editor-form.tsx | 653 ++++++++++++------ .../management/monitor-editor-panel.tsx | 53 +- .../portfolio-condition-builder.tsx | 411 +++++++++++ .../use-monitor-editor-state.test.tsx | 18 +- .../management/use-monitor-editor-state.ts | 56 +- .../monitor/components/shared/monitor-ui.tsx | 31 +- .../monitor/components/shared/types.ts | 106 ++- .../monitor/components/shared/utils.ts | 50 +- .../timeline/monitor-timeline.test.tsx | 8 + .../timeline/timeline-state.test.ts | 5 +- .../monitor-config-workspace.test.tsx | 39 +- .../workspace/monitor-config-workspace.tsx | 10 +- .../monitor-execution-workspace.test.tsx | 24 + .../[workspaceId]/monitor/monitor.test.tsx | 42 +- .../[workspaceId]/monitor/monitor.tsx | 34 +- .../indicator-monitor-execution.test.ts | 99 +-- .../background/indicator-monitor-execution.ts | 137 ++-- .../background/monitor-disable.ts | 33 + .../background/monitor-execution.ts | 39 ++ .../pending-execution-drain.test.ts | 22 +- .../background/pending-execution-drain.ts | 13 +- .../background/portfolio-monitor-execution.ts | 119 ++++ .../background/webhook-execution.ts | 50 -- .../background/workflow-execution.test.ts | 38 + .../background/workflow-execution.ts | 35 +- .../blocks/blocks/historical_data.ts | 12 +- ...rading_holdings.ts => portfolio_detail.ts} | 40 +- .../blocks/blocks/trading_action.ts | 18 +- .../blocks/trading_order_contracts.test.ts | 95 ++- apps/tradinggoose/blocks/registry.ts | 6 +- apps/tradinggoose/blocks/types.ts | 6 +- apps/tradinggoose/blocks/utils.ts | 65 +- .../listing-selector/selector/dropdown.tsx | 2 +- .../listing-selector/selector/input.tsx | 8 +- .../market-selector/provider-controls.tsx} | 12 +- .../provider-selector.test.tsx} | 22 +- .../market-selector/provider-selector.tsx | 46 ++ .../provider-settings-button.test.tsx} | 2 +- .../provider-settings-button.tsx} | 6 +- .../oauth}/oauth-required-modal.test.tsx | 0 .../oauth}/oauth-required-modal.tsx | 0 .../provider-selector.tsx} | 107 ++- .../account-selector.test.tsx} | 32 +- .../trading-selector/account-selector.tsx} | 60 +- .../trading-selector/provider-controls.tsx} | 10 +- .../provider-selector.test.tsx} | 22 +- .../trading-selector/provider-selector.tsx | 69 ++ .../trading-selector/services.test.ts} | 2 +- .../trading-selector/services.ts} | 3 +- .../components/ui/dropdown-menu.tsx | 9 +- apps/tradinggoose/components/ui/select.tsx | 31 +- .../components/widget-header-control.ts | 0 apps/tradinggoose/hooks/queries/logs.ts | 2 +- .../hooks/queries/trading-portfolio.ts | 22 +- .../lib/copilot/monitor/monitor-documents.ts | 65 +- .../lib/copilot/tool-prompt-metadata.ts | 2 +- .../tools/client/monitor/edit-monitor.ts | 58 +- .../tools/client/monitor/list-monitors.ts | 7 +- .../client/monitor/monitor-tool-utils.ts | 66 +- .../client/monitor/monitor-tools.test.ts | 23 +- .../lib/execution/pending-execution.ts | 7 +- apps/tradinggoose/lib/indicators/dispatch.ts | 9 +- .../lib/indicators/monitor-config.test.ts | 25 +- .../lib/indicators/monitor-config.ts | 29 +- .../lib/monitors/portfolio-conditions.ts | 215 ++++++ .../lib/monitors/portfolio-config.ts | 167 +++++ apps/tradinggoose/lib/monitors/sources.ts | 74 ++ apps/tradinggoose/lib/trading/context.ts | 21 +- apps/tradinggoose/lib/trading/order-detail.ts | 11 +- .../lib/trading/order-records.test.ts | 4 +- .../tradinggoose/lib/trading/order-records.ts | 4 +- apps/tradinggoose/lib/trading/orders.ts | 7 +- .../{holdings.ts => portfolio-detail.ts} | 33 +- .../lib/trading/portfolio-identities.test.ts | 61 +- .../lib/trading/portfolio-identities.ts | 113 ++- apps/tradinggoose/lib/webhooks/utils.ts | 3 +- .../lib/workflows/db-helpers.test.ts | 6 - apps/tradinggoose/lib/workflows/db-helpers.ts | 30 +- .../lib/workflows/execution-runner.test.ts | 19 + .../lib/workflows/execution-runner.ts | 25 +- .../providers/market/alpaca/config.ts | 1 - .../providers/market/alpha-vantage/config.ts | 1 - .../providers/market/finnhub/config.ts | 1 - .../providers/market/providers.ts | 90 ++- .../providers/market/yahoo-finance/config.ts | 1 - .../providers/trading/alpaca/accounts.ts | 4 +- .../providers/trading/alpaca/config.ts | 4 +- .../providers/trading/alpaca/performance.ts | 7 +- .../trading/alpaca/portfolio.test.ts | 18 +- .../trading/alpaca/positions.test.ts | 17 +- .../providers/trading/alpaca/positions.ts | 23 +- .../providers/trading/portfolio-detail.ts | 12 +- .../providers/trading/portfolio-identity.ts | 10 +- .../providers/trading/portfolio-selectors.ts | 2 +- .../providers/trading/portfolio.test.ts | 6 +- .../providers/trading/portfolio.ts | 9 +- .../providers/trading/providers.ts | 14 +- .../providers/trading/tradier/accounts.ts | 4 +- .../providers/trading/tradier/client.ts | 4 +- .../providers/trading/tradier/config.ts | 4 +- .../providers/trading/tradier/orderDetail.ts | 4 +- .../providers/trading/tradier/performance.ts | 4 +- .../trading/tradier/portfolio.test.ts | 13 +- .../providers/trading/tradier/positions.ts | 15 +- apps/tradinggoose/providers/trading/types.ts | 13 +- apps/tradinggoose/socket-server/index.ts | 26 +- .../market/indicator-monitor-runtime.test.ts | 36 +- .../market/indicator-monitor-runtime.ts | 478 ++++--------- .../socket-server/market/manager.test.ts | 111 ++- .../socket-server/market/manager.ts | 239 +++++-- .../socket-server/monitor-runtime-lock.ts | 130 ++++ .../tradinggoose/socket-server/routes/http.ts | 95 +-- .../trading/portfolio-manager.test.ts | 40 +- .../trading/portfolio-manager.ts | 357 +++++++--- .../trading/portfolio-monitor-runtime.test.ts | 260 +++++++ .../trading/portfolio-monitor-runtime.ts | 578 ++++++++++++++++ apps/tradinggoose/tools/params.ts | 2 + apps/tradinggoose/tools/registry.ts | 4 +- .../tradinggoose/tools/trading/action.test.ts | 4 +- apps/tradinggoose/tools/trading/index.ts | 4 +- ...dings.test.ts => portfolio-detail.test.ts} | 38 +- .../{holdings.ts => portfolio-detail.ts} | 23 +- .../blocks/portfolio_state_trigger.ts | 34 + .../triggers/portfolio/trigger.ts | 35 + apps/tradinggoose/triggers/registry.ts | 2 + .../widgets/utils/heatmap-params.test.ts | 2 +- .../utils/portfolio-snapshot-params.test.tsx | 2 +- .../widgets/utils/quick-order-params.test.tsx | 2 +- .../utils/trading-widget-providers.test.ts | 2 +- .../components/custom-tool-dropdown.tsx | 10 +- .../widgets/components/mcp-dropdown.tsx | 8 +- .../components/pair-color-dropdown.tsx | 6 +- .../components/pine-indicator-dropdown.tsx | 12 +- .../widgets/components/skill-dropdown.tsx | 8 +- .../components/trading-provider-selector.tsx | 156 ----- .../use-portfolio-identity-selection.ts | 2 +- .../widgets/components/widget-action-menu.tsx | 4 +- .../widget-header-refresh-button.tsx | 2 +- .../widgets/components/widget-selector.tsx | 8 +- .../widgets/components/workflow-dropdown.tsx | 12 +- .../components/copilot/copilot-header.tsx | 17 +- .../data_chart/components/chart-controls.tsx | 10 +- .../widgets/data_chart/components/footer.tsx | 31 +- .../components/listing-control.test.tsx | 7 +- .../components/provider-controls.tsx | 2 +- .../widgets/editor_custom_tool/index.tsx | 6 +- .../widgets/widgets/editor_mcp/index.tsx | 6 +- .../deployment-controls.tsx | 9 +- .../export-controls/export-controls.tsx | 2 +- .../components/control-bar/control-bar.tsx | 8 +- .../credential-selector.tsx | 2 +- .../components/confluence-file-selector.tsx | 2 +- .../components/google-drive-picker.tsx | 2 +- .../components/jira-issue-selector.tsx | 2 +- .../components/microsoft-file-selector.tsx | 2 +- .../components/teams-message-selector.tsx | 2 +- .../components/wealthbox-file-selector.tsx | 2 +- .../folder-selector/folder-selector.tsx | 2 +- .../components/jira-project-selector.tsx | 2 +- .../components/tool-credential-selector.tsx | 2 +- .../components/tool-input/tool-input.tsx | 2 + .../components/sub-block/sub-block.tsx | 219 +++++- .../workflow-block/workflow-block.tsx | 14 +- .../workflow-controlbar/controlbar.tsx | 13 +- .../workflow-toolbar/workflow-toolbar.tsx | 12 +- .../widgets/heatmap/components/body.test.tsx | 20 +- .../widgets/heatmap/components/body.tsx | 28 +- .../heatmap/components/header.test.tsx | 12 +- .../widgets/heatmap/components/header.tsx | 8 +- .../widgets/heatmap/components/shared.ts | 8 +- .../widgets/list_custom_tool/index.test.tsx | 2 +- .../widgets/list_custom_tool/index.tsx | 16 +- .../components/indicator-create-menu.tsx | 4 +- .../widgets/list_indicator/index.test.tsx | 2 +- .../widgets/widgets/list_indicator/index.tsx | 2 +- .../widgets/widgets/list_mcp/index.tsx | 16 +- .../components/skill-create-menu.tsx | 4 +- .../widgets/widgets/list_skill/index.test.tsx | 2 +- .../widgets/widgets/list_skill/index.tsx | 2 +- .../components/workflow-create-menu.tsx | 14 +- .../widgets/widgets/list_workflow/index.tsx | 2 +- .../components/body.test.tsx | 16 +- .../portfolio_snapshot/components/body.tsx | 36 +- .../components/header.test.tsx | 16 +- .../portfolio_snapshot/components/header.tsx | 6 +- .../portfolio_snapshot/components/shared.ts | 15 +- .../quick_order/components/body.test.tsx | 3 +- .../widgets/quick_order/components/body.tsx | 1 + .../quick_order/components/header.test.tsx | 12 +- .../widgets/quick_order/components/header.tsx | 6 +- .../components/provider-controls.tsx | 269 -------- .../components/watchlist-header-controls.tsx | 10 +- .../watchlist-header-controls.ui.test.tsx | 2 +- ...watchlist-header-left-controls.ui.test.tsx | 4 +- .../watchlist-list-actions-button.test.tsx | 15 +- .../watchlist-list-actions-button.tsx | 2 +- .../watchlist-list-selector.test.tsx | 32 +- .../components/watchlist-list-selector.tsx | 15 +- .../widgets/workflow_chat/index.test.tsx | 2 +- .../widgets/widgets/workflow_chat/index.tsx | 16 +- .../widgets/workflow_console/index.tsx | 8 +- .../widgets/workflow_variables/index.tsx | 8 +- changelog/May-29-2026.md | 89 +++ 259 files changed, 7014 insertions(+), 3428 deletions(-) delete mode 100644 apps/tradinggoose/app/api/indicator-monitors/[id]/route.ts delete mode 100644 apps/tradinggoose/app/api/indicator-monitors/route.ts create mode 100644 apps/tradinggoose/app/api/monitors/[id]/route.ts rename apps/tradinggoose/app/api/{indicator-monitors => monitors}/reconcile.ts (64%) create mode 100644 apps/tradinggoose/app/api/monitors/route.ts rename apps/tradinggoose/app/api/{indicator-monitors => monitors}/shared.ts (55%) rename apps/tradinggoose/app/api/tools/trading/{holdings => portfolio-detail}/route.ts (73%) create mode 100644 apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/data/use-monitor-reference-data.test.tsx create mode 100644 apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/management/portfolio-condition-builder.tsx create mode 100644 apps/tradinggoose/background/monitor-disable.ts create mode 100644 apps/tradinggoose/background/monitor-execution.ts create mode 100644 apps/tradinggoose/background/portfolio-monitor-execution.ts rename apps/tradinggoose/blocks/blocks/{trading_holdings.ts => portfolio_detail.ts} (51%) rename apps/tradinggoose/{widgets/widgets/components/market-provider-controls.tsx => components/market-selector/provider-controls.tsx} (82%) rename apps/tradinggoose/{widgets/widgets/components/market-provider-selector.test.tsx => components/market-selector/provider-selector.test.tsx} (69%) create mode 100644 apps/tradinggoose/components/market-selector/provider-selector.tsx rename apps/tradinggoose/{widgets/widgets/components/market-provider-settings-button.test.tsx => components/market-selector/provider-settings-button.test.tsx} (98%) rename apps/tradinggoose/{widgets/widgets/components/market-provider-settings-button.tsx => components/market-selector/provider-settings-button.tsx} (98%) rename apps/tradinggoose/{widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/credential-selector/components => components/oauth}/oauth-required-modal.test.tsx (100%) rename apps/tradinggoose/{widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/credential-selector/components => components/oauth}/oauth-required-modal.tsx (100%) rename apps/tradinggoose/{widgets/widgets/components/market-provider-selector.tsx => components/provider-selector.tsx} (50%) rename apps/tradinggoose/{widgets/widgets/components/trading-account-selector.test.tsx => components/trading-selector/account-selector.test.tsx} (86%) rename apps/tradinggoose/{widgets/widgets/components/trading-account-selector.tsx => components/trading-selector/account-selector.tsx} (84%) rename apps/tradinggoose/{widgets/widgets/components/trading-provider-controls.tsx => components/trading-selector/provider-controls.tsx} (89%) rename apps/tradinggoose/{widgets/widgets/components/trading-provider-selector.test.tsx => components/trading-selector/provider-selector.test.tsx} (70%) create mode 100644 apps/tradinggoose/components/trading-selector/provider-selector.tsx rename apps/tradinggoose/{widgets/widgets/components/trading-services.test.ts => components/trading-selector/services.test.ts} (92%) rename apps/tradinggoose/{widgets/widgets/components/trading-services.ts => components/trading-selector/services.ts} (95%) rename apps/tradinggoose/{widgets/widgets => }/components/widget-header-control.ts (100%) create mode 100644 apps/tradinggoose/lib/monitors/portfolio-conditions.ts create mode 100644 apps/tradinggoose/lib/monitors/portfolio-config.ts create mode 100644 apps/tradinggoose/lib/monitors/sources.ts rename apps/tradinggoose/lib/trading/{holdings.ts => portfolio-detail.ts} (67%) create mode 100644 apps/tradinggoose/socket-server/monitor-runtime-lock.ts create mode 100644 apps/tradinggoose/socket-server/trading/portfolio-monitor-runtime.test.ts create mode 100644 apps/tradinggoose/socket-server/trading/portfolio-monitor-runtime.ts rename apps/tradinggoose/tools/trading/{holdings.test.ts => portfolio-detail.test.ts} (72%) rename apps/tradinggoose/tools/trading/{holdings.ts => portfolio-detail.ts} (59%) create mode 100644 apps/tradinggoose/triggers/blocks/portfolio_state_trigger.ts create mode 100644 apps/tradinggoose/triggers/portfolio/trigger.ts delete mode 100644 apps/tradinggoose/widgets/widgets/components/trading-provider-selector.tsx delete mode 100644 apps/tradinggoose/widgets/widgets/watchlist/components/provider-controls.tsx create mode 100644 changelog/May-29-2026.md diff --git a/apps/tradinggoose/app/(landing)/components/feature/components/market-preview/landing-indicator-dropdown.tsx b/apps/tradinggoose/app/(landing)/components/feature/components/market-preview/landing-indicator-dropdown.tsx index a46429c59..c2162b136 100644 --- a/apps/tradinggoose/app/(landing)/components/feature/components/market-preview/landing-indicator-dropdown.tsx +++ b/apps/tradinggoose/app/(landing)/components/feature/components/market-preview/landing-indicator-dropdown.tsx @@ -11,13 +11,13 @@ import { import { Input } from '@/components/ui/input' import { ScrollArea } from '@/components/ui/scroll-area' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { cn } from '@/lib/utils' import { widgetHeaderControlClassName, widgetHeaderMenuContentClassName, widgetHeaderMenuItemClassName, widgetHeaderMenuTextClassName, -} from '@/widgets/widgets/components/widget-header-control' +} from '@/components/widget-header-control' +import { cn } from '@/lib/utils' import type { LandingMarketIndicatorOption } from './indicators/catalog' const DEFAULT_PLACEHOLDER = 'Select indicators' diff --git a/apps/tradinggoose/app/(landing)/components/feature/components/market-preview/landing-widget-shell.tsx b/apps/tradinggoose/app/(landing)/components/feature/components/market-preview/landing-widget-shell.tsx index 00134730b..3175fea60 100644 --- a/apps/tradinggoose/app/(landing)/components/feature/components/market-preview/landing-widget-shell.tsx +++ b/apps/tradinggoose/app/(landing)/components/feature/components/market-preview/landing-widget-shell.tsx @@ -3,9 +3,9 @@ import type { ReactNode, WheelEvent } from 'react' import { useCallback } from 'react' import { Card } from '@/components/ui/card' +import { widgetHeaderControlClassName } from '@/components/widget-header-control' import { cn } from '@/lib/utils' import { getWidgetDefinition } from '@/widgets/registry' -import { widgetHeaderControlClassName } from '@/widgets/widgets/components/widget-header-control' type LandingWidgetShellProps = { widgetKey: string diff --git a/apps/tradinggoose/app/(landing)/components/feature/components/market-preview/market-preview.tsx b/apps/tradinggoose/app/(landing)/components/feature/components/market-preview/market-preview.tsx index 57bb1220f..0518ecd05 100644 --- a/apps/tradinggoose/app/(landing)/components/feature/components/market-preview/market-preview.tsx +++ b/apps/tradinggoose/app/(landing)/components/feature/components/market-preview/market-preview.tsx @@ -1,6 +1,7 @@ 'use client' import React from 'react' +import { widgetHeaderButtonGroupClassName } from '@/components/widget-header-control' import { executeBrowserPineIndicator } from '@/lib/indicators/browser-execution' import { buildInputsMapFromMeta } from '@/lib/indicators/input-meta' import { buildIndexMaps, mapMarketSeriesToBarsMs } from '@/lib/indicators/series-data' @@ -16,7 +17,6 @@ import { emitDataChartParamsChange, useDataChartParamsPersistence, } from '@/widgets/utils/chart-params' -import { widgetHeaderButtonGroupClassName } from '@/widgets/widgets/components/widget-header-control' import { DataChartCandleTypeDropdown } from '@/widgets/widgets/data_chart/components/chart-controls' import { ChartPaneOverlays } from '@/widgets/widgets/data_chart/components/chart-pane-overlays' import { DrawToolsSidebar } from '@/widgets/widgets/data_chart/components/draw-tools-sidebar' diff --git a/apps/tradinggoose/app/(landing)/components/feature/components/workflow-preview/workflow-preview.tsx b/apps/tradinggoose/app/(landing)/components/feature/components/workflow-preview/workflow-preview.tsx index ac80e11a0..cb60bc533 100644 --- a/apps/tradinggoose/app/(landing)/components/feature/components/workflow-preview/workflow-preview.tsx +++ b/apps/tradinggoose/app/(landing)/components/feature/components/workflow-preview/workflow-preview.tsx @@ -13,7 +13,7 @@ import { widgetHeaderMenuContentClassName, widgetHeaderMenuItemClassName, widgetHeaderMenuTextClassName, -} from '@/widgets/widgets/components/widget-header-control' +} from '@/components/widget-header-control' import { LandingWidgetShell } from '../market-preview/landing-widget-shell' import { WorkflowPreviewCanvas } from './workflow-preview-canvas' import { TRADING_AGENT_WORKFLOW_DEMOS, type WorkflowPreviewDemo } from './workflow-preview-demos' diff --git a/apps/tradinggoose/app/api/indicator-monitors/[id]/route.ts b/apps/tradinggoose/app/api/indicator-monitors/[id]/route.ts deleted file mode 100644 index 847fa917b..000000000 --- a/apps/tradinggoose/app/api/indicator-monitors/[id]/route.ts +++ /dev/null @@ -1,271 +0,0 @@ -import { db, webhook } from '@tradinggoose/db' -import { and, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { - INDICATOR_MONITOR_TRIGGER_ID, - type IndicatorMonitorProviderConfig, - IndicatorMonitorUpdateSchema, - normalizeIndicatorMonitorConfig, -} from '@/lib/indicators/monitor-config' -import { createLogger } from '@/lib/logs/console/logger' -import { generateRequestId } from '@/lib/utils' -import { notifyIndicatorMonitorsReconcile } from '@/app/api/indicator-monitors/reconcile' -import { authenticateIndicatorRequest, checkWorkspacePermission } from '@/app/api/indicators/utils' -import { - ensureIndicatorTriggerBlockInDeployedState, - ensureTriggerCapableIndicator, - ensureWorkflowInWorkspace, - getIndicatorMonitorRowById, - loadIndicatorInputMetadata, - toIndicatorMonitorRecord, -} from '../shared' - -const logger = createLogger('IndicatorMonitorByIdAPI') - -export const dynamic = 'force-dynamic' -export const runtime = 'nodejs' - -const clientErrorPatterns = ['Missing', 'Invalid', 'not found', 'must be', 'does not', 'Unable to'] - -const isClientError = (message: string, error: unknown) => - error instanceof Error && - clientErrorPatterns.some((pattern) => message.toLowerCase().includes(pattern.toLowerCase())) - -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - - try { - const auth = await authenticateIndicatorRequest({ - request, - requestId, - logger, - action: 'monitor read', - responseShape: 'errorOnly', - }) - if ('response' in auth) return auth.response - - const { id } = await params - const row = await getIndicatorMonitorRowById(id) - if (!row) { - return NextResponse.json({ error: 'Monitor not found' }, { status: 404 }) - } - - if (!row.workflow.workspaceId) { - return NextResponse.json({ error: 'Monitor workspace is missing' }, { status: 400 }) - } - - const permission = await checkWorkspacePermission({ - userId: auth.userId, - workspaceId: row.workflow.workspaceId, - responseShape: 'errorOnly', - }) - if (!permission.ok) return permission.response - - return NextResponse.json({ data: await toIndicatorMonitorRecord(row.webhook) }, { status: 200 }) - } catch (error) { - logger.error(`[${requestId}] Failed to load indicator monitor`, { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} - -export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - - try { - const auth = await authenticateIndicatorRequest({ - request, - requestId, - logger, - action: 'monitor update', - responseShape: 'errorOnly', - }) - if ('response' in auth) return auth.response - - const { id } = await params - const row = await getIndicatorMonitorRowById(id) - if (!row) { - return NextResponse.json({ error: 'Monitor not found' }, { status: 404 }) - } - - const body = await request.json().catch(() => ({})) - const parsed = IndicatorMonitorUpdateSchema.safeParse(body) - if (!parsed.success) { - return NextResponse.json( - { error: parsed.error.errors[0]?.message ?? 'Invalid request' }, - { status: 400 } - ) - } - - const payload = parsed.data - const workspaceId = row.workflow.workspaceId - if (!workspaceId) { - return NextResponse.json({ error: 'Monitor workspace is missing' }, { status: 400 }) - } - if (payload.workspaceId !== undefined && payload.workspaceId !== workspaceId) { - return NextResponse.json( - { error: 'workspaceId does not match monitor workspace' }, - { status: 400 } - ) - } - - const permission = await checkWorkspacePermission({ - userId: auth.userId, - workspaceId, - requireWrite: true, - responseShape: 'errorOnly', - }) - if (!permission.ok) return permission.response - - const existingConfig = (row.webhook.providerConfig || {}) as IndicatorMonitorProviderConfig - const existingMonitor = existingConfig.monitor - if (!existingMonitor) { - return NextResponse.json({ error: 'Invalid existing monitor config' }, { status: 500 }) - } - - const nextWorkflowId = payload.workflowId ?? row.webhook.workflowId - const nextTriggerBlockId = payload.blockId ?? existingMonitor.triggerBlockId - if (!nextTriggerBlockId) { - return NextResponse.json({ error: 'blockId is required' }, { status: 400 }) - } - - const workflowRow = await ensureWorkflowInWorkspace(nextWorkflowId, workspaceId) - if ( - payload.blockId !== undefined || - payload.workflowId !== undefined || - payload.isActive === true - ) { - await ensureIndicatorTriggerBlockInDeployedState(nextWorkflowId, nextTriggerBlockId) - } - const nextProviderId = payload.providerId ?? existingMonitor.providerId - const providerChanged = nextProviderId !== existingMonitor.providerId - const nextIndicatorId = payload.indicatorId ?? existingMonitor.indicatorId - const indicatorChanged = nextIndicatorId !== existingMonitor.indicatorId - const authProvided = Object.hasOwn(payload, 'auth') - const providerParamsProvided = Object.hasOwn(payload, 'providerParams') - const indicatorInputsProvided = Object.hasOwn(payload, 'indicatorInputs') - const shouldNormalizeIndicatorInputs = indicatorInputsProvided || indicatorChanged - - await ensureTriggerCapableIndicator(workspaceId, nextIndicatorId) - const indicatorMetadata = shouldNormalizeIndicatorInputs - ? await loadIndicatorInputMetadata(workspaceId, nextIndicatorId) - : null - - const nextProviderParams = providerChanged - ? providerParamsProvided - ? (payload.providerParams ?? {}) - : undefined - : providerParamsProvided - ? (payload.providerParams ?? {}) - : existingMonitor.providerParams - const nextIndicatorInputs = shouldNormalizeIndicatorInputs - ? indicatorInputsProvided - ? (payload.indicatorInputs ?? {}) - : {} - : undefined - const nextIsActive = - payload.isActive === undefined - ? row.webhook.isActive - : payload.isActive && workflowRow.isDeployed - - const providerConfig = await normalizeIndicatorMonitorConfig({ - triggerBlockId: nextTriggerBlockId, - providerId: nextProviderId, - interval: payload.interval ?? existingMonitor.interval, - listingInput: payload.listing ?? existingMonitor.listing, - indicatorId: nextIndicatorId, - authInput: authProvided ? payload.auth : undefined, - providerParams: nextProviderParams, - indicatorInputs: nextIndicatorInputs, - indicatorInputMeta: indicatorMetadata?.inputMeta, - previousAuth: providerChanged ? undefined : existingMonitor.auth, - requireCompleteAuth: nextIsActive, - }) - if (!shouldNormalizeIndicatorInputs && typeof existingMonitor.indicatorInputs !== 'undefined') { - providerConfig.monitor.indicatorInputs = existingMonitor.indicatorInputs - } - - const [updatedMonitor] = await db - .update(webhook) - .set({ - workflowId: nextWorkflowId, - blockId: null, - providerConfig: { - ...providerConfig, - triggerId: INDICATOR_MONITOR_TRIGGER_ID, - }, - isActive: nextIsActive, - updatedAt: new Date(), - }) - .where( - and( - eq(webhook.id, id), - eq(webhook.provider, 'indicator'), - eq(webhook.workflowId, row.workflow.id) - ) - ) - .returning() - - void notifyIndicatorMonitorsReconcile({ requestId, logger }) - - if (!updatedMonitor) { - return NextResponse.json({ error: 'Monitor not found' }, { status: 404 }) - } - - return NextResponse.json( - { data: await toIndicatorMonitorRecord(updatedMonitor) }, - { status: 200 } - ) - } catch (error) { - const message = error instanceof Error ? error.message : 'Internal server error' - logger.error(`[${requestId}] Failed to update indicator monitor`, { error }) - if (isClientError(message, error)) { - return NextResponse.json({ error: message }, { status: 400 }) - } - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} - -export async function DELETE( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const requestId = generateRequestId() - - try { - const auth = await authenticateIndicatorRequest({ - request, - requestId, - logger, - action: 'monitor delete', - responseShape: 'errorOnly', - }) - if ('response' in auth) return auth.response - - const { id } = await params - const row = await getIndicatorMonitorRowById(id) - if (!row) { - return NextResponse.json({ error: 'Monitor not found' }, { status: 404 }) - } - - if (!row.workflow.workspaceId) { - return NextResponse.json({ error: 'Monitor workspace is missing' }, { status: 400 }) - } - - const workspaceId = row.workflow.workspaceId - const permission = await checkWorkspacePermission({ - userId: auth.userId, - workspaceId, - requireWrite: true, - responseShape: 'errorOnly', - }) - if (!permission.ok) return permission.response - - await db.delete(webhook).where(and(eq(webhook.id, id), eq(webhook.provider, 'indicator'))) - void notifyIndicatorMonitorsReconcile({ requestId, logger }) - - return NextResponse.json({ success: true }, { status: 200 }) - } catch (error) { - logger.error(`[${requestId}] Failed to delete indicator monitor`, { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} diff --git a/apps/tradinggoose/app/api/indicator-monitors/route.ts b/apps/tradinggoose/app/api/indicator-monitors/route.ts deleted file mode 100644 index 3fd41bcc2..000000000 --- a/apps/tradinggoose/app/api/indicator-monitors/route.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { db, webhook } from '@tradinggoose/db' -import { nanoid } from 'nanoid' -import { type NextRequest, NextResponse } from 'next/server' -import { - INDICATOR_MONITOR_TRIGGER_ID, - IndicatorMonitorCreateSchema, - normalizeIndicatorMonitorConfig, -} from '@/lib/indicators/monitor-config' -import { createLogger } from '@/lib/logs/console/logger' -import { generateRequestId } from '@/lib/utils' -import { notifyIndicatorMonitorsReconcile } from '@/app/api/indicator-monitors/reconcile' -import { authenticateIndicatorRequest, checkWorkspacePermission } from '@/app/api/indicators/utils' -import { - ensureIndicatorTriggerBlockInDeployedState, - ensureTriggerCapableIndicator, - ensureWorkflowInWorkspace, - listIndicatorMonitorRows, - loadIndicatorInputMetadata, - toIndicatorMonitorRecord, -} from './shared' - -const logger = createLogger('IndicatorMonitorsAPI') - -export const dynamic = 'force-dynamic' -export const runtime = 'nodejs' - -export async function GET(request: NextRequest) { - const requestId = generateRequestId() - - try { - const auth = await authenticateIndicatorRequest({ - request, - requestId, - logger, - action: 'monitor list', - responseShape: 'errorOnly', - }) - if ('response' in auth) return auth.response - - const { searchParams } = new URL(request.url) - const workspaceId = searchParams.get('workspaceId')?.trim() - const workflowId = searchParams.get('workflowId')?.trim() || undefined - const blockId = searchParams.get('blockId')?.trim() || undefined - - if (!workspaceId) { - return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 }) - } - - const permission = await checkWorkspacePermission({ - userId: auth.userId, - workspaceId, - responseShape: 'errorOnly', - }) - if (!permission.ok) return permission.response - - const rows = await listIndicatorMonitorRows({ workspaceId, workflowId, blockId }) - return NextResponse.json( - { - data: await Promise.all(rows.map((row) => toIndicatorMonitorRecord(row.webhook))), - }, - { status: 200 } - ) - } catch (error) { - logger.error(`[${requestId}] Failed to list indicator monitors`, { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} - -export async function POST(request: NextRequest) { - const requestId = generateRequestId() - - try { - const auth = await authenticateIndicatorRequest({ - request, - requestId, - logger, - action: 'monitor create', - responseShape: 'errorOnly', - }) - if ('response' in auth) return auth.response - - const body = await request.json().catch(() => ({})) - const parsed = IndicatorMonitorCreateSchema.safeParse(body) - if (!parsed.success) { - return NextResponse.json( - { error: parsed.error.errors[0]?.message ?? 'Invalid request' }, - { status: 400 } - ) - } - - const payload = parsed.data - const permission = await checkWorkspacePermission({ - userId: auth.userId, - workspaceId: payload.workspaceId, - requireWrite: true, - responseShape: 'errorOnly', - }) - if (!permission.ok) return permission.response - - const workflowRow = await ensureWorkflowInWorkspace(payload.workflowId, payload.workspaceId) - await ensureIndicatorTriggerBlockInDeployedState(payload.workflowId, payload.blockId) - await ensureTriggerCapableIndicator(payload.workspaceId, payload.indicatorId) - const indicatorMetadata = await loadIndicatorInputMetadata( - payload.workspaceId, - payload.indicatorId - ) - const nextIsActive = (payload.isActive ?? true) && workflowRow.isDeployed === true - - const providerConfig = await normalizeIndicatorMonitorConfig({ - triggerBlockId: payload.blockId, - providerId: payload.providerId, - interval: payload.interval, - listingInput: payload.listing, - indicatorId: payload.indicatorId, - authInput: payload.auth, - providerParams: payload.providerParams, - indicatorInputs: payload.indicatorInputs, - indicatorInputMeta: indicatorMetadata.inputMeta, - requireCompleteAuth: nextIsActive, - }) - - const monitorId = nanoid() - const monitorPath = `indicator-monitor-${monitorId}` - - const [createdMonitor] = await db - .insert(webhook) - .values({ - id: monitorId, - workflowId: payload.workflowId, - blockId: null, - path: monitorPath, - provider: 'indicator', - providerConfig: { - ...providerConfig, - triggerId: INDICATOR_MONITOR_TRIGGER_ID, - }, - isActive: nextIsActive, - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning() - - void notifyIndicatorMonitorsReconcile({ requestId, logger }) - - return NextResponse.json( - { data: await toIndicatorMonitorRecord(createdMonitor) }, - { status: 201 } - ) - } catch (error) { - const message = error instanceof Error ? error.message : 'Internal server error' - const clientErrorPatterns = [ - 'Missing', - 'Invalid', - 'not found', - 'must be', - 'does not', - 'Unable to', - ] - const isClientError = - error instanceof Error && - clientErrorPatterns.some((pattern) => message.toLowerCase().includes(pattern.toLowerCase())) - - logger.error(`[${requestId}] Failed to create indicator monitor`, { error }) - if (isClientError) { - return NextResponse.json({ error: message }, { status: 400 }) - } - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} diff --git a/apps/tradinggoose/app/api/logs/export/route.ts b/apps/tradinggoose/app/api/logs/export/route.ts index 55d20783b..431d056d8 100644 --- a/apps/tradinggoose/app/api/logs/export/route.ts +++ b/apps/tradinggoose/app/api/logs/export/route.ts @@ -11,6 +11,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' +import { isMonitorTriggerId } from '@/lib/monitors/sources' import { buildWorkspaceAccessScope } from '@/lib/permissions/utils' import { normalizeOptionalString } from '@/lib/utils' import { @@ -25,6 +26,14 @@ const logger = createLogger('LogsExportAPI') export const revalidate = 0 const EXPORT_PAGE_SIZE = 1000 +const MonitorTriggerSourceParamSchema = z + .preprocess((value) => { + if (typeof value !== 'string') return value + const trimmed = value.trim() + return trimmed.length === 0 ? undefined : trimmed + }, z.string().optional()) + .refine((value) => !value || splitCsv(value).every(isMonitorTriggerId), 'Invalid triggerSource') + const ExportParamsSchema = z.object({ level: z.string().optional(), excludeLevel: z.string().optional(), @@ -72,11 +81,7 @@ const ExportParamsSchema = z.object({ costMinExclusive: z.string().optional(), costMax: z.coerce.number().optional(), costMaxExclusive: z.string().optional(), - triggerSource: z.preprocess((value) => { - if (typeof value !== 'string') return value - const trimmed = value.trim() - return trimmed.length === 0 ? undefined : trimmed - }, z.literal('indicator_trigger').optional()), + triggerSource: MonitorTriggerSourceParamSchema, workspaceId: z.string(), }) @@ -271,10 +276,14 @@ export async function GET(request: NextRequest) { parseBooleanFlag(params.costMaxExclusive) ) - if (params.triggerSource) { + const triggerSources = splitCsv(params.triggerSource) + if (triggerSources.length > 0) { conditions = and( conditions, - sql`${workflowExecutionLogs.executionData}->'trigger'->>'source' = ${params.triggerSource}` + inArray( + sql`${workflowExecutionLogs.executionData}->'trigger'->>'source'`, + triggerSources + ) ) } diff --git a/apps/tradinggoose/app/api/logs/log-utils.ts b/apps/tradinggoose/app/api/logs/log-utils.ts index 5afcf208f..ca929116a 100644 --- a/apps/tradinggoose/app/api/logs/log-utils.ts +++ b/apps/tradinggoose/app/api/logs/log-utils.ts @@ -11,7 +11,14 @@ import { normalizeOptionalString } from '@/lib/utils' const isRecord = (value: unknown): value is Record => Boolean(value) && typeof value === 'object' && !Array.isArray(value) -const MONITOR_FIELDS = ['id', 'providerId', 'interval', 'indicatorId'] as const +const MONITOR_FIELDS = [ + 'id', + 'providerId', + 'serviceId', + 'accountId', + 'interval', + 'indicatorId', +] as const const MONITOR_LISTING_FIELDS = [ 'listing_type', 'listing_id', diff --git a/apps/tradinggoose/app/api/logs/route.ts b/apps/tradinggoose/app/api/logs/route.ts index f77eaaaa4..f13d60d9d 100644 --- a/apps/tradinggoose/app/api/logs/route.ts +++ b/apps/tradinggoose/app/api/logs/route.ts @@ -12,6 +12,7 @@ import { z } from 'zod' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' import type { WorkflowLogOutcome } from '@/lib/logs/types' +import { isMonitorTriggerId } from '@/lib/monitors/sources' import { buildWorkspaceAccessScope } from '@/lib/permissions/utils' import { generateRequestId, normalizeOptionalString } from '@/lib/utils' import { @@ -24,6 +25,14 @@ const logger = createLogger('LogsAPI') export const revalidate = 0 +const MonitorTriggerSourceParamSchema = z + .preprocess((value) => { + if (typeof value !== 'string') return value + const trimmed = value.trim() + return trimmed.length === 0 ? undefined : trimmed + }, z.string().optional()) + .refine((value) => !value || splitCsv(value).every(isMonitorTriggerId), 'Invalid triggerSource') + const QueryParamsSchema = z.object({ details: z.enum(['basic', 'full']).optional().default('basic'), limit: z.coerce.number().int().min(1).optional().default(100), @@ -74,11 +83,7 @@ const QueryParamsSchema = z.object({ costMinExclusive: z.string().optional(), costMax: z.coerce.number().optional(), costMaxExclusive: z.string().optional(), - triggerSource: z.preprocess((value) => { - if (typeof value !== 'string') return value - const trimmed = value.trim() - return trimmed.length === 0 ? undefined : trimmed - }, z.literal('indicator_trigger').optional()), + triggerSource: MonitorTriggerSourceParamSchema, workspaceId: z.string(), }) @@ -463,10 +468,14 @@ export async function GET(request: NextRequest) { : applyCostLowerBound(conditions, value, parseBooleanFlag(exclusive)) } - if (params.triggerSource) { + const triggerSources = splitCsv(params.triggerSource) + if (triggerSources.length > 0) { conditions = and( conditions, - sql`${workflowExecutionLogs.executionData}->'trigger'->>'source' = ${params.triggerSource}` + inArray( + sql`${workflowExecutionLogs.executionData}->'trigger'->>'source'`, + triggerSources + ) ) } diff --git a/apps/tradinggoose/app/api/monitors/[id]/route.ts b/apps/tradinggoose/app/api/monitors/[id]/route.ts new file mode 100644 index 000000000..1ad271d75 --- /dev/null +++ b/apps/tradinggoose/app/api/monitors/[id]/route.ts @@ -0,0 +1,395 @@ +import { isDeepStrictEqual } from 'node:util' +import { db, webhook } from '@tradinggoose/db' +import { and, eq, inArray } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { + type IndicatorMonitorProviderConfig, + IndicatorMonitorUpdateSchema, + normalizeIndicatorMonitorConfig, +} from '@/lib/indicators/monitor-config' +import { createLogger } from '@/lib/logs/console/logger' +import { + normalizePortfolioMonitorConfig, + type PortfolioMonitorProviderConfig, + PortfolioMonitorUpdateSchema, +} from '@/lib/monitors/portfolio-config' +import { + getMonitorTriggerIdForProvider, + MONITOR_WEBHOOK_PROVIDERS, + type MonitorWebhookProvider, + PORTFOLIO_MONITOR_PROVIDER, +} from '@/lib/monitors/sources' +import { generateRequestId } from '@/lib/utils' +import { authenticateIndicatorRequest, checkWorkspacePermission } from '@/app/api/indicators/utils' +import { notifyMonitorsReconcile } from '@/app/api/monitors/reconcile' +import { getTradingProviderOAuthServiceId } from '@/providers/trading/providers' +import type { TradingProviderId } from '@/providers/trading/types' +import { + ensureMonitorTriggerBlockInDeployedState, + ensureTriggerCapableIndicator, + ensureWorkflowInWorkspace, + getMonitorRowById, + isMonitorClientError, + loadIndicatorInputMetadata, + MonitorRequestError, + resolvePortfolioMonitorAccount, + toMonitorRecord, +} from '../shared' + +const logger = createLogger('MonitorByIdAPI') + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +type IndicatorUpdatePayload = ReturnType +type PortfolioUpdatePayload = ReturnType +type MonitorUpdatePayload = IndicatorUpdatePayload | PortfolioUpdatePayload + +const parseUpdatePayload = ( + source: MonitorWebhookProvider, + body: unknown +): MonitorUpdatePayload => { + const parsed = + source === PORTFOLIO_MONITOR_PROVIDER + ? PortfolioMonitorUpdateSchema.safeParse(body) + : IndicatorMonitorUpdateSchema.safeParse(body) + + if (!parsed.success) { + throw new MonitorRequestError(parsed.error.errors[0]?.message ?? 'Invalid request') + } + + return parsed.data +} + +export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const requestId = generateRequestId() + + try { + const auth = await authenticateIndicatorRequest({ + request, + requestId, + logger, + action: 'monitor read', + responseShape: 'errorOnly', + }) + if ('response' in auth) return auth.response + + const { id } = await params + const row = await getMonitorRowById(id) + if (!row) { + return NextResponse.json({ error: 'Monitor not found' }, { status: 404 }) + } + + if (!row.workflow.workspaceId) { + return NextResponse.json({ error: 'Monitor workspace is missing' }, { status: 400 }) + } + + const permission = await checkWorkspacePermission({ + userId: auth.userId, + workspaceId: row.workflow.workspaceId, + responseShape: 'errorOnly', + }) + if (!permission.ok) return permission.response + + return NextResponse.json({ data: await toMonitorRecord(row.webhook) }, { status: 200 }) + } catch (error) { + logger.error(`[${requestId}] Failed to load monitor`, { error }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} + +export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const requestId = generateRequestId() + + try { + const auth = await authenticateIndicatorRequest({ + request, + requestId, + logger, + action: 'monitor update', + responseShape: 'errorOnly', + }) + if ('response' in auth) return auth.response + + const { id } = await params + const row = await getMonitorRowById(id) + if (!row) { + return NextResponse.json({ error: 'Monitor not found' }, { status: 404 }) + } + + const body = await request.json().catch(() => ({})) + const source = row.webhook.provider as MonitorWebhookProvider + const payload = parseUpdatePayload(source, body) + const workspaceId = row.workflow.workspaceId + if (!workspaceId) { + return NextResponse.json({ error: 'Monitor workspace is missing' }, { status: 400 }) + } + if (payload.workspaceId !== workspaceId) { + return NextResponse.json( + { error: 'workspaceId does not match monitor workspace' }, + { status: 400 } + ) + } + + const permission = await checkWorkspacePermission({ + userId: auth.userId, + workspaceId, + requireWrite: true, + responseShape: 'errorOnly', + }) + if (!permission.ok) return permission.response + + const existingConfig = row.webhook.providerConfig as + | IndicatorMonitorProviderConfig + | PortfolioMonitorProviderConfig + const existingMonitor = existingConfig.monitor + if (!existingMonitor) { + return NextResponse.json({ error: 'Invalid existing monitor config' }, { status: 500 }) + } + + const nextWorkflowId = payload.workflowId ?? row.webhook.workflowId + const nextTriggerBlockId = payload.blockId ?? existingMonitor.triggerBlockId + if (!nextTriggerBlockId) { + return NextResponse.json({ error: 'blockId is required' }, { status: 400 }) + } + + const workflowRow = await ensureWorkflowInWorkspace(nextWorkflowId, workspaceId) + if ( + payload.blockId !== undefined || + payload.workflowId !== undefined || + payload.isActive === true + ) { + await ensureMonitorTriggerBlockInDeployedState( + nextWorkflowId, + nextTriggerBlockId, + getMonitorTriggerIdForProvider(source) + ) + } + const nextIsActive = + payload.isActive === undefined + ? row.webhook.isActive + : payload.isActive && workflowRow.isDeployed + + const providerConfig = await buildProviderConfigForUpdate({ + source, + payload, + existingConfig, + nextTriggerBlockId, + workspaceId, + userId: auth.userId, + requestId, + requireCompleteAuth: nextIsActive, + }) + + const [updatedMonitor] = await db + .update(webhook) + .set({ + workflowId: nextWorkflowId, + blockId: null, + providerConfig, + isActive: nextIsActive, + updatedAt: new Date(), + }) + .where( + and( + eq(webhook.id, id), + eq(webhook.provider, source), + eq(webhook.workflowId, row.workflow.id) + ) + ) + .returning() + + void notifyMonitorsReconcile({ requestId, logger }) + + if (!updatedMonitor) { + return NextResponse.json({ error: 'Monitor not found' }, { status: 404 }) + } + + return NextResponse.json({ data: await toMonitorRecord(updatedMonitor) }, { status: 200 }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Internal server error' + logger.error(`[${requestId}] Failed to update monitor`, { error }) + if (error instanceof MonitorRequestError || isMonitorClientError(message)) { + return NextResponse.json( + { + error: message, + }, + { + status: error instanceof MonitorRequestError ? error.status : 400, + } + ) + } + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const requestId = generateRequestId() + + try { + const auth = await authenticateIndicatorRequest({ + request, + requestId, + logger, + action: 'monitor delete', + responseShape: 'errorOnly', + }) + if ('response' in auth) return auth.response + + const { id } = await params + const row = await getMonitorRowById(id) + if (!row) { + return NextResponse.json({ error: 'Monitor not found' }, { status: 404 }) + } + + if (!row.workflow.workspaceId) { + return NextResponse.json({ error: 'Monitor workspace is missing' }, { status: 400 }) + } + + const workspaceId = row.workflow.workspaceId + const permission = await checkWorkspacePermission({ + userId: auth.userId, + workspaceId, + requireWrite: true, + responseShape: 'errorOnly', + }) + if (!permission.ok) return permission.response + + await db + .delete(webhook) + .where(and(eq(webhook.id, id), inArray(webhook.provider, [...MONITOR_WEBHOOK_PROVIDERS]))) + void notifyMonitorsReconcile({ requestId, logger }) + + return NextResponse.json({ success: true }, { status: 200 }) + } catch (error) { + logger.error(`[${requestId}] Failed to delete monitor`, { error }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} + +async function buildProviderConfigForUpdate({ + source, + payload, + existingConfig, + nextTriggerBlockId, + workspaceId, + userId, + requestId, + requireCompleteAuth, +}: { + source: MonitorWebhookProvider + payload: MonitorUpdatePayload + existingConfig: IndicatorMonitorProviderConfig | PortfolioMonitorProviderConfig + nextTriggerBlockId: string + workspaceId: string + userId: string + requestId: string + requireCompleteAuth: boolean +}) { + if (source === PORTFOLIO_MONITOR_PROVIDER) { + const portfolioPayload = payload as PortfolioUpdatePayload + const portfolioConfig = existingConfig as PortfolioMonitorProviderConfig + const existingMonitor = portfolioConfig.monitor + const nextProviderId = portfolioPayload.providerId ?? existingMonitor.providerId + const nextCredentialId = portfolioPayload.credentialId ?? existingMonitor.credentialId + const nextAccountId = portfolioPayload.accountId ?? existingMonitor.accountId + const requestedServiceId = portfolioPayload.serviceId ?? existingMonitor.serviceId + const requestedOAuthServiceId = getTradingProviderOAuthServiceId( + nextProviderId as TradingProviderId, + requestedServiceId + ) + if (!requestedOAuthServiceId) { + throw new MonitorRequestError('Trading provider connection is required') + } + const connectionChanged = + nextProviderId !== existingMonitor.providerId || + requestedOAuthServiceId !== existingMonitor.serviceId || + nextCredentialId !== existingMonitor.credentialId || + nextAccountId !== existingMonitor.accountId + const connection = + requireCompleteAuth || connectionChanged + ? await resolvePortfolioMonitorAccount({ + userId, + providerId: nextProviderId, + serviceId: requestedOAuthServiceId, + credentialId: nextCredentialId, + accountId: nextAccountId, + requestId, + }) + : { + serviceId: existingMonitor.serviceId, + connectionOwnerUserId: existingMonitor.connectionOwnerUserId, + } + + const providerConfig = normalizePortfolioMonitorConfig({ + triggerBlockId: nextTriggerBlockId, + providerId: nextProviderId, + serviceId: connection.serviceId, + credentialId: nextCredentialId, + connectionOwnerUserId: connection.connectionOwnerUserId, + accountId: nextAccountId, + condition: portfolioPayload.condition ?? existingMonitor.condition, + fireMode: portfolioPayload.fireMode ?? existingMonitor.fireMode, + cooldownSeconds: portfolioPayload.cooldownSeconds ?? existingMonitor.cooldownSeconds, + pollIntervalSeconds: + portfolioPayload.pollIntervalSeconds ?? existingMonitor.pollIntervalSeconds, + }) + const shouldPreserveRuntimeState = isDeepStrictEqual( + providerConfig.monitor, + portfolioConfig.monitor + ) + if (shouldPreserveRuntimeState && portfolioConfig.runtimeState !== undefined) { + providerConfig.runtimeState = portfolioConfig.runtimeState + } + return providerConfig + } + + const indicatorPayload = payload as IndicatorUpdatePayload + const existingMonitor = (existingConfig as IndicatorMonitorProviderConfig).monitor + const nextProviderId = indicatorPayload.providerId ?? existingMonitor.providerId + const providerChanged = nextProviderId !== existingMonitor.providerId + const nextIndicatorId = indicatorPayload.indicatorId ?? existingMonitor.indicatorId + const indicatorChanged = nextIndicatorId !== existingMonitor.indicatorId + const authProvided = Object.hasOwn(indicatorPayload, 'auth') + const providerParamsProvided = Object.hasOwn(indicatorPayload, 'providerParams') + const indicatorInputsProvided = Object.hasOwn(indicatorPayload, 'indicatorInputs') + const shouldNormalizeIndicatorInputs = indicatorInputsProvided || indicatorChanged + + await ensureTriggerCapableIndicator(workspaceId, nextIndicatorId) + const indicatorMetadata = shouldNormalizeIndicatorInputs + ? await loadIndicatorInputMetadata(workspaceId, nextIndicatorId) + : null + const nextProviderParams = providerChanged + ? providerParamsProvided + ? (indicatorPayload.providerParams ?? {}) + : undefined + : providerParamsProvided + ? (indicatorPayload.providerParams ?? {}) + : existingMonitor.providerParams + const nextIndicatorInputs = shouldNormalizeIndicatorInputs + ? indicatorInputsProvided + ? (indicatorPayload.indicatorInputs ?? {}) + : {} + : undefined + + const providerConfig = await normalizeIndicatorMonitorConfig({ + triggerBlockId: nextTriggerBlockId, + providerId: nextProviderId, + interval: indicatorPayload.interval ?? existingMonitor.interval, + listingInput: indicatorPayload.listing ?? existingMonitor.listing, + indicatorId: nextIndicatorId, + authInput: authProvided ? indicatorPayload.auth : undefined, + providerParams: nextProviderParams, + indicatorInputs: nextIndicatorInputs, + indicatorInputMeta: indicatorMetadata?.inputMeta, + previousAuth: providerChanged ? undefined : existingMonitor.auth, + requireCompleteAuth, + }) + if (!shouldNormalizeIndicatorInputs && typeof existingMonitor.indicatorInputs !== 'undefined') { + providerConfig.monitor.indicatorInputs = existingMonitor.indicatorInputs + } + return providerConfig +} diff --git a/apps/tradinggoose/app/api/indicator-monitors/reconcile.ts b/apps/tradinggoose/app/api/monitors/reconcile.ts similarity index 64% rename from apps/tradinggoose/app/api/indicator-monitors/reconcile.ts rename to apps/tradinggoose/app/api/monitors/reconcile.ts index 320e4dc62..3c97e29e1 100644 --- a/apps/tradinggoose/app/api/indicator-monitors/reconcile.ts +++ b/apps/tradinggoose/app/api/monitors/reconcile.ts @@ -4,7 +4,7 @@ type Logger = { warn: (message: string, ...args: unknown[]) => void } -export const notifyIndicatorMonitorsReconcile = async ({ +export const notifyMonitorsReconcile = async ({ requestId, logger, }: { @@ -13,7 +13,7 @@ export const notifyIndicatorMonitorsReconcile = async ({ }) => { try { const socketUrl = getInternalRealtimeUrl() - const response = await fetch(`${socketUrl}/internal/indicator-monitors/reconcile`, { + const response = await fetch(`${socketUrl}/internal/monitors/reconcile`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -23,11 +23,11 @@ export const notifyIndicatorMonitorsReconcile = async ({ }) if (!response.ok) { - logger.warn(`[${requestId}] Indicator monitor reconcile notification failed`, { + logger.warn(`[${requestId}] Monitor reconcile notification failed`, { status: response.status, }) } } catch (error) { - logger.warn(`[${requestId}] Indicator monitor reconcile notification error`, { error }) + logger.warn(`[${requestId}] Monitor reconcile notification error`, { error }) } } diff --git a/apps/tradinggoose/app/api/monitors/route.ts b/apps/tradinggoose/app/api/monitors/route.ts new file mode 100644 index 000000000..d25e99348 --- /dev/null +++ b/apps/tradinggoose/app/api/monitors/route.ts @@ -0,0 +1,249 @@ +import { db, webhook } from '@tradinggoose/db' +import { nanoid } from 'nanoid' +import { type NextRequest, NextResponse } from 'next/server' +import { + IndicatorMonitorCreateSchema, + normalizeIndicatorMonitorConfig, +} from '@/lib/indicators/monitor-config' +import { createLogger } from '@/lib/logs/console/logger' +import { + normalizePortfolioMonitorConfig, + PortfolioMonitorCreateSchema, +} from '@/lib/monitors/portfolio-config' +import { + getMonitorTriggerIdForProvider, + isMonitorProvider, + type MonitorWebhookProvider, + PORTFOLIO_MONITOR_PROVIDER, +} from '@/lib/monitors/sources' +import { generateRequestId } from '@/lib/utils' +import { authenticateIndicatorRequest, checkWorkspacePermission } from '@/app/api/indicators/utils' +import { notifyMonitorsReconcile } from '@/app/api/monitors/reconcile' +import { + ensureMonitorTriggerBlockInDeployedState, + ensureTriggerCapableIndicator, + ensureWorkflowInWorkspace, + isMonitorClientError, + listMonitorRows, + loadIndicatorInputMetadata, + MonitorRequestError, + resolvePortfolioMonitorAccount, + toMonitorRecord, +} from './shared' + +const logger = createLogger('MonitorsAPI') + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +type IndicatorCreatePayload = ReturnType +type PortfolioCreatePayload = ReturnType +type MonitorCreatePayload = IndicatorCreatePayload | PortfolioCreatePayload + +const parseCreatePayload = ( + source: MonitorWebhookProvider, + body: unknown +): MonitorCreatePayload => { + const parsed = + source === PORTFOLIO_MONITOR_PROVIDER + ? PortfolioMonitorCreateSchema.safeParse(body) + : IndicatorMonitorCreateSchema.safeParse(body) + + if (!parsed.success) { + throw new MonitorRequestError(parsed.error.errors[0]?.message ?? 'Invalid request') + } + + return parsed.data +} + +export async function GET(request: NextRequest) { + const requestId = generateRequestId() + + try { + const auth = await authenticateIndicatorRequest({ + request, + requestId, + logger, + action: 'monitor list', + responseShape: 'errorOnly', + }) + if ('response' in auth) return auth.response + + const { searchParams } = new URL(request.url) + const workspaceId = searchParams.get('workspaceId')?.trim() + const workflowId = searchParams.get('workflowId')?.trim() || undefined + const blockId = searchParams.get('blockId')?.trim() || undefined + const source = searchParams.get('source')?.trim() || undefined + + if (!workspaceId) { + return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 }) + } + + const permission = await checkWorkspacePermission({ + userId: auth.userId, + workspaceId, + responseShape: 'errorOnly', + }) + if (!permission.ok) return permission.response + + if (source && !isMonitorProvider(source)) { + return NextResponse.json({ error: 'Invalid monitor source' }, { status: 400 }) + } + + const rows = await listMonitorRows({ + workspaceId, + workflowId, + blockId, + source: source as MonitorWebhookProvider | undefined, + }) + return NextResponse.json( + { + data: await Promise.all(rows.map((row) => toMonitorRecord(row.webhook))), + }, + { status: 200 } + ) + } catch (error) { + logger.error(`[${requestId}] Failed to list monitors`, { error }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} + +export async function POST(request: NextRequest) { + const requestId = generateRequestId() + + try { + const auth = await authenticateIndicatorRequest({ + request, + requestId, + logger, + action: 'monitor create', + responseShape: 'errorOnly', + }) + if ('response' in auth) return auth.response + + const body = await request.json().catch(() => ({})) + const source = body && typeof body === 'object' ? (body as { source?: unknown }).source : null + if (!isMonitorProvider(source)) { + return NextResponse.json({ error: 'source is required' }, { status: 400 }) + } + + const payload = parseCreatePayload(source, body) + const permission = await checkWorkspacePermission({ + userId: auth.userId, + workspaceId: payload.workspaceId, + requireWrite: true, + responseShape: 'errorOnly', + }) + if (!permission.ok) return permission.response + + const workflowRow = await ensureWorkflowInWorkspace(payload.workflowId, payload.workspaceId) + await ensureMonitorTriggerBlockInDeployedState( + payload.workflowId, + payload.blockId, + getMonitorTriggerIdForProvider(source) + ) + const nextIsActive = (payload.isActive ?? true) && workflowRow.isDeployed === true + const providerConfig = await buildProviderConfigForCreate({ + source, + payload, + userId: auth.userId, + requestId, + requireCompleteAuth: nextIsActive, + }) + + const monitorId = nanoid() + const monitorPath = `monitor-${monitorId}` + + const [createdMonitor] = await db + .insert(webhook) + .values({ + id: monitorId, + workflowId: payload.workflowId, + blockId: null, + path: monitorPath, + provider: source, + providerConfig, + isActive: nextIsActive, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning() + + void notifyMonitorsReconcile({ requestId, logger }) + + return NextResponse.json({ data: await toMonitorRecord(createdMonitor) }, { status: 201 }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Internal server error' + logger.error(`[${requestId}] Failed to create monitor`, { error }) + if (error instanceof MonitorRequestError || isMonitorClientError(message)) { + return NextResponse.json( + { + error: message, + }, + { + status: error instanceof MonitorRequestError ? error.status : 400, + } + ) + } + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} + +async function buildProviderConfigForCreate({ + source, + payload, + userId, + requestId, + requireCompleteAuth, +}: { + source: MonitorWebhookProvider + payload: MonitorCreatePayload + userId: string + requestId: string + requireCompleteAuth: boolean +}) { + if (source === PORTFOLIO_MONITOR_PROVIDER) { + const portfolioPayload = payload as PortfolioCreatePayload + const connection = await resolvePortfolioMonitorAccount({ + userId, + providerId: portfolioPayload.providerId, + serviceId: portfolioPayload.serviceId, + credentialId: portfolioPayload.credentialId, + accountId: portfolioPayload.accountId, + requestId, + }) + + return normalizePortfolioMonitorConfig({ + triggerBlockId: portfolioPayload.blockId, + providerId: portfolioPayload.providerId, + serviceId: connection.serviceId, + credentialId: portfolioPayload.credentialId, + connectionOwnerUserId: connection.connectionOwnerUserId, + accountId: portfolioPayload.accountId, + condition: portfolioPayload.condition, + fireMode: portfolioPayload.fireMode, + cooldownSeconds: portfolioPayload.cooldownSeconds, + pollIntervalSeconds: portfolioPayload.pollIntervalSeconds, + }) + } + + const indicatorPayload = payload as IndicatorCreatePayload + await ensureTriggerCapableIndicator(indicatorPayload.workspaceId, indicatorPayload.indicatorId) + const indicatorMetadata = await loadIndicatorInputMetadata( + indicatorPayload.workspaceId, + indicatorPayload.indicatorId + ) + + return normalizeIndicatorMonitorConfig({ + triggerBlockId: indicatorPayload.blockId, + providerId: indicatorPayload.providerId, + interval: indicatorPayload.interval, + listingInput: indicatorPayload.listing, + indicatorId: indicatorPayload.indicatorId, + authInput: indicatorPayload.auth, + providerParams: indicatorPayload.providerParams, + indicatorInputs: indicatorPayload.indicatorInputs, + indicatorInputMeta: indicatorMetadata.inputMeta, + requireCompleteAuth, + }) +} diff --git a/apps/tradinggoose/app/api/indicator-monitors/shared.ts b/apps/tradinggoose/app/api/monitors/shared.ts similarity index 55% rename from apps/tradinggoose/app/api/indicator-monitors/shared.ts rename to apps/tradinggoose/app/api/monitors/shared.ts index 0c635e26c..23880d7e5 100644 --- a/apps/tradinggoose/app/api/indicator-monitors/shared.ts +++ b/apps/tradinggoose/app/api/monitors/shared.ts @@ -5,7 +5,7 @@ import { workflow, workflowDeploymentVersion, } from '@tradinggoose/db/schema' -import { and, desc, eq } from 'drizzle-orm' +import { and, desc, eq, inArray } from 'drizzle-orm' import { DEFAULT_INDICATOR_RUNTIME_MAP } from '@/lib/indicators/default/runtime' import { normalizeInputMetaMap } from '@/lib/indicators/input-meta' import { @@ -15,24 +15,71 @@ import { import { isIndicatorTriggerCapable } from '@/lib/indicators/trigger-detection' import type { InputMetaMap } from '@/lib/indicators/types' import { resolveListingIdentity } from '@/lib/listing/resolve' +import { + type PortfolioMonitorProviderConfig, + toPublicPortfolioMonitorProviderConfig, +} from '@/lib/monitors/portfolio-config' +import { + getMonitorTriggerIdForProvider, + INDICATOR_MONITOR_PROVIDER, + isMonitorProvider, + isMonitorProviderConfigForProvider, + MONITOR_WEBHOOK_PROVIDERS, + type MonitorTriggerId, + type MonitorWebhookProvider, + PORTFOLIO_MONITOR_PROVIDER, +} from '@/lib/monitors/sources' +import { + authorizeTradingConnectionRequest, + resolveTradingProviderContext, + resolveTradingProviderSelectedAccount, +} from '@/lib/trading/context' +import { isTradingServiceError } from '@/lib/trading/errors' import { applySavedEntityYjsStateToRows } from '@/lib/yjs/entity-state' -const INDICATOR_PROVIDER = 'indicator' - type WebhookRow = typeof webhook.$inferSelect -export const listIndicatorMonitorRows = async ({ +export class MonitorRequestError extends Error { + status: number + + constructor(message: string, status = 400) { + super(message) + this.name = 'MonitorRequestError' + this.status = status + } +} + +const MONITOR_CLIENT_ERROR_PATTERNS = [ + 'Missing', + 'Invalid', + 'not found', + 'must be', + 'does not', + 'Unable to', + 'no active deployment', +] + +export const isMonitorClientError = (message: string) => + MONITOR_CLIENT_ERROR_PATTERNS.some((pattern) => + message.toLowerCase().includes(pattern.toLowerCase()) + ) + +export const listMonitorRows = async ({ workspaceId, workflowId, blockId, + source, }: { workspaceId: string workflowId?: string blockId?: string + source?: MonitorWebhookProvider }) => { const conditions = [ eq(workflow.workspaceId, workspaceId), - eq(webhook.provider, INDICATOR_PROVIDER), + source + ? eq(webhook.provider, source) + : inArray(webhook.provider, [...MONITOR_WEBHOOK_PROVIDERS]), ] if (workflowId) { @@ -53,17 +100,15 @@ export const listIndicatorMonitorRows = async ({ if (!blockId) return rows return rows.filter((row) => { - try { - return ( - parseIndicatorProviderConfig(row.webhook.providerConfig).monitor.triggerBlockId === blockId - ) - } catch { - return false - } + if (!isMonitorProvider(row.webhook.provider)) return false + return ( + getTriggerBlockIdFromMonitorConfig(row.webhook.providerConfig, row.webhook.provider) === + blockId + ) }) } -export const getIndicatorMonitorRowById = async (id: string) => { +export const getMonitorRowById = async (id: string) => { const rows = await db .select({ webhook: webhook, @@ -74,7 +119,7 @@ export const getIndicatorMonitorRowById = async (id: string) => { }) .from(webhook) .innerJoin(workflow, eq(webhook.workflowId, workflow.id)) - .where(and(eq(webhook.id, id), eq(webhook.provider, INDICATOR_PROVIDER))) + .where(and(eq(webhook.id, id), inArray(webhook.provider, [...MONITOR_WEBHOOK_PROVIDERS]))) .limit(1) return rows[0] ?? null @@ -100,8 +145,9 @@ const getActiveDeployedState = async (workflowId: string) => { return rows[0]?.state as Record | undefined } -const getDeployedIndicatorTriggerBlockIds = ( - deployedState: Record | undefined +const getDeployedMonitorTriggerBlockIds = ( + deployedState: Record | undefined, + triggerId: MonitorTriggerId ) => { const blocks = deployedState && typeof deployedState === 'object' @@ -112,7 +158,7 @@ const getDeployedIndicatorTriggerBlockIds = ( const ids = Object.entries(blocks) .map(([blockId, blockData]) => { const block = blockData as { id?: unknown; type?: unknown } | undefined - if (block?.type !== 'indicator_trigger') return null + if (block?.type !== triggerId) return null return toTrimmedString(block?.id) ?? toTrimmedString(blockId) }) .filter((value): value is string => Boolean(value)) @@ -120,18 +166,19 @@ const getDeployedIndicatorTriggerBlockIds = ( return new Set(ids) } -export const ensureIndicatorTriggerBlockInDeployedState = async ( +export const ensureMonitorTriggerBlockInDeployedState = async ( workflowId: string, - blockId: string + blockId: string, + triggerId: MonitorTriggerId ) => { const deployedState = await getActiveDeployedState(workflowId) if (!deployedState) { throw new Error('Target workflow has no active deployment.') } - const triggerBlockIds = getDeployedIndicatorTriggerBlockIds(deployedState) + const triggerBlockIds = getDeployedMonitorTriggerBlockIds(deployedState, triggerId) if (!triggerBlockIds.has(blockId)) { - throw new Error('Target block must be an indicator_trigger block in the active deployment.') + throw new Error(`Target block must be a ${triggerId} block in the active deployment.`) } } @@ -157,6 +204,59 @@ export const ensureWorkflowInWorkspace = async (workflowId: string, workspaceId: return workflowRow } +export const resolvePortfolioMonitorAccount = async ({ + userId, + providerId, + serviceId, + credentialId, + accountId, + requestId, +}: { + userId: string + providerId: string + serviceId?: string | null + credentialId: string + accountId: string + requestId: string +}) => { + const requestedServiceId = serviceId?.trim() + if (!requestedServiceId) { + throw new MonitorRequestError('Trading provider connection is required') + } + + try { + const connection = await authorizeTradingConnectionRequest({ + credentialId, + userId, + }) + const baseContext = await resolveTradingProviderContext({ + requestData: { + provider: providerId, + credentialId, + serviceId: requestedServiceId, + }, + requestId, + userId, + connectionOwnerUserId: connection.connectionOwnerUserId, + tokenAccountId: connection.tokenAccountId, + accountProviderId: connection.accountProviderId, + }) + await resolveTradingProviderSelectedAccount({ + baseContext, + accountId, + }) + return { + serviceId: baseContext.serviceId, + connectionOwnerUserId: connection.connectionOwnerUserId, + } + } catch (error) { + if (isTradingServiceError(error)) { + throw new MonitorRequestError(error.message, error.status) + } + throw error + } +} + export const ensureTriggerCapableIndicator = async (workspaceId: string, indicatorId: string) => { const defaultIndicator = DEFAULT_INDICATOR_RUNTIME_MAP.get(indicatorId) if (defaultIndicator) { @@ -226,13 +326,30 @@ export const loadIndicatorInputMetadata = async ( const parseIndicatorProviderConfig = ( providerConfig: WebhookRow['providerConfig'] ): IndicatorMonitorProviderConfig => { - if (!providerConfig || typeof providerConfig !== 'object') { + if (!isMonitorProviderConfigForProvider(providerConfig, INDICATOR_MONITOR_PROVIDER)) { throw new Error('Invalid monitor provider config.') } return providerConfig as IndicatorMonitorProviderConfig } -export const toIndicatorMonitorRecord = async (webhookRow: WebhookRow) => { +const parsePortfolioProviderConfig = ( + providerConfig: WebhookRow['providerConfig'] +): PortfolioMonitorProviderConfig => { + if (!isMonitorProviderConfigForProvider(providerConfig, PORTFOLIO_MONITOR_PROVIDER)) { + throw new Error('Invalid monitor provider config.') + } + return providerConfig as PortfolioMonitorProviderConfig +} + +const getTriggerBlockIdFromMonitorConfig = ( + providerConfig: WebhookRow['providerConfig'], + provider: MonitorWebhookProvider +) => { + if (!isMonitorProviderConfigForProvider(providerConfig, provider)) return null + return toTrimmedString(providerConfig.monitor.triggerBlockId) +} + +const toIndicatorProviderRecord = async (webhookRow: WebhookRow) => { const providerConfig = parseIndicatorProviderConfig(webhookRow.providerConfig) const publicProviderConfig = toPublicIndicatorMonitorProviderConfig(providerConfig) const resolvedListing = await resolveListingIdentity(publicProviderConfig.monitor.listing).catch( @@ -245,6 +362,7 @@ export const toIndicatorMonitorRecord = async (webhookRow: WebhookRow) => { return { monitorId: webhookRow.id, + source: INDICATOR_MONITOR_PROVIDER, workflowId: webhookRow.workflowId, blockId: providerConfig.monitor.triggerBlockId, isActive: webhookRow.isActive, @@ -260,37 +378,65 @@ export const toIndicatorMonitorRecord = async (webhookRow: WebhookRow) => { } } -export const pauseMonitorsMissingDeployedIndicatorTrigger = async (workflowId: string) => { +export const toMonitorRecord = async (webhookRow: WebhookRow) => { + if (!isMonitorProvider(webhookRow.provider)) { + throw new Error('Unsupported monitor provider.') + } + + if (webhookRow.provider === INDICATOR_MONITOR_PROVIDER) { + return toIndicatorProviderRecord(webhookRow) + } + + const providerConfig = parsePortfolioProviderConfig(webhookRow.providerConfig) + const publicProviderConfig = toPublicPortfolioMonitorProviderConfig(providerConfig) + + return { + monitorId: webhookRow.id, + source: webhookRow.provider, + workflowId: webhookRow.workflowId, + blockId: providerConfig.monitor.triggerBlockId, + isActive: webhookRow.isActive, + providerConfig: publicProviderConfig, + createdAt: webhookRow.createdAt.toISOString(), + updatedAt: webhookRow.updatedAt.toISOString(), + } +} + +export const pauseMonitorsMissingDeployedTrigger = async (workflowId: string) => { const deployedState = await getActiveDeployedState(workflowId) - const deployedTriggerBlockIds = getDeployedIndicatorTriggerBlockIds(deployedState) + const deployedTriggerBlockIdsByProvider = Object.fromEntries( + MONITOR_WEBHOOK_PROVIDERS.map((provider) => [ + provider, + getDeployedMonitorTriggerBlockIds(deployedState, getMonitorTriggerIdForProvider(provider)), + ]) + ) as Record> const rows = await db .select({ id: webhook.id, - blockId: webhook.blockId, + provider: webhook.provider, isActive: webhook.isActive, providerConfig: webhook.providerConfig, }) .from(webhook) - .where(and(eq(webhook.workflowId, workflowId), eq(webhook.provider, INDICATOR_PROVIDER))) + .where( + and( + eq(webhook.workflowId, workflowId), + inArray(webhook.provider, [...MONITOR_WEBHOOK_PROVIDERS]) + ) + ) const now = new Date() for (const row of rows) { - let providerConfig: IndicatorMonitorProviderConfig - try { - providerConfig = parseIndicatorProviderConfig(row.providerConfig) - } catch { - continue - } - const triggerBlockId = toTrimmedString(providerConfig.monitor.triggerBlockId) + if (!isMonitorProvider(row.provider)) continue + const triggerBlockId = getTriggerBlockIdFromMonitorConfig(row.providerConfig, row.provider) if (!triggerBlockId) continue - if (deployedTriggerBlockIds.has(triggerBlockId)) continue - if (!row.isActive && row.blockId === null) continue + if (deployedTriggerBlockIdsByProvider[row.provider].has(triggerBlockId)) continue + if (!row.isActive) continue await db .update(webhook) .set({ isActive: false, - blockId: null, updatedAt: now, }) .where(eq(webhook.id, row.id)) diff --git a/apps/tradinggoose/app/api/orders/[orderId]/provider-detail/route.test.ts b/apps/tradinggoose/app/api/orders/[orderId]/provider-detail/route.test.ts index 42e78d9e4..556cb6246 100644 --- a/apps/tradinggoose/app/api/orders/[orderId]/provider-detail/route.test.ts +++ b/apps/tradinggoose/app/api/orders/[orderId]/provider-detail/route.test.ts @@ -123,7 +123,7 @@ const orderRow = { listingIdentity: { listing_type: 'stock', listing_id: 'AAPL' }, request: { accountId: 'account-1', - tokenAccountId: 'oauth-account-1', + credentialId: 'oauth-credential-1', serviceId: 'alpaca-paper', side: 'buy', }, @@ -140,6 +140,7 @@ describe('order provider detail route', () => { mocks.checkWorkspaceAccess.mockResolvedValue({ exists: true, hasAccess: true }) vi.mocked(authorizeTradingConnectionRequest).mockResolvedValue({ connectionOwnerUserId: 'connection-owner-1', + tokenAccountId: 'oauth-account-1', accountProviderId: 'alpaca-paper', }) vi.mocked(resolveTradingProviderContext).mockResolvedValue({ @@ -173,18 +174,19 @@ describe('order provider detail route', () => { expect(mocks.eq).toHaveBeenCalledWith('orderHistoryTable.id', 'order-1') expect(mocks.eq).toHaveBeenCalledWith('orderHistoryTable.workspaceId', 'workspace-1') expect(authorizeTradingConnectionRequest).toHaveBeenCalledWith({ - tokenAccountId: 'oauth-account-1', + credentialId: 'oauth-credential-1', userId: 'user-1', }) expect(resolveTradingProviderContext).toHaveBeenCalledWith({ requestData: { - tokenAccountId: 'oauth-account-1', + credentialId: 'oauth-credential-1', serviceId: 'alpaca-paper', provider: 'alpaca', }, requestId: 'request-1', userId: 'user-1', connectionOwnerUserId: 'connection-owner-1', + tokenAccountId: 'oauth-account-1', accountProviderId: 'alpaca-paper', }) expect(executeTradingProviderOrderDetailRequest).toHaveBeenCalledWith( diff --git a/apps/tradinggoose/app/api/providers/trading/order/route.test.ts b/apps/tradinggoose/app/api/providers/trading/order/route.test.ts index 09d06aeb9..ff09260cd 100644 --- a/apps/tradinggoose/app/api/providers/trading/order/route.test.ts +++ b/apps/tradinggoose/app/api/providers/trading/order/route.test.ts @@ -98,7 +98,7 @@ const workspaceId = 'workspace-1' const portfolioIdentityFor = (providerId: 'alpaca' | 'tradier', accountId = 'ACC-1') => ({ providerId, - tokenAccountId: `${providerId}-oauth-account-1`, + credentialId: `${providerId}-oauth-credential-1`, serviceId: `${providerId}-live`, accountId, }) @@ -156,8 +156,8 @@ describe('Trading provider order route', () => { mockResolveOAuthConnectionAccountForUser.mockImplementation( ({ accountId }: { accountId: string }) => Promise.resolve({ - credentialOwnerUserId: 'user-1', tokenAccountId: accountId, + credentialOwnerUserId: 'user-1', providerId: accountId.startsWith('tradier') ? 'tradier-live' : 'alpaca-live', }) ) @@ -177,7 +177,7 @@ describe('Trading provider order route', () => { mockListPortfolioIdentities.mockResolvedValue([ { providerId: 'alpaca', - tokenAccountId: 'alpaca-oauth-account-1', + credentialId: 'alpaca-oauth-credential-1', serviceId: 'alpaca-live', accountId: 'ACC-1', accountName: 'Main', @@ -187,7 +187,7 @@ describe('Trading provider order route', () => { }, { providerId: 'tradier', - tokenAccountId: 'tradier-oauth-account-1', + credentialId: 'tradier-oauth-credential-1', serviceId: 'tradier-live', accountId: 'ACC-1', accountName: 'Main', @@ -242,8 +242,8 @@ describe('Trading provider order route', () => { it('rejects portfolio identities whose connection service does not match the requested service', async () => { mockResolveOAuthConnectionAccountForUser.mockResolvedValueOnce({ + tokenAccountId: 'tradier-oauth-credential-1', credentialOwnerUserId: 'user-1', - tokenAccountId: 'tradier-oauth-account-1', providerId: 'alpaca-live', }) @@ -522,7 +522,7 @@ describe('Trading provider order route', () => { mockListPortfolioIdentities.mockResolvedValue([ { providerId: 'tradier', - tokenAccountId: 'tradier-oauth-account-1', + credentialId: 'tradier-oauth-credential-1', serviceId: 'tradier-live', accountId: 'ACC-2', }, @@ -609,7 +609,7 @@ describe('Trading provider order route', () => { }) expect(mockFetch).toHaveBeenCalledTimes(1) expect(mockRefreshAccessTokenIfNeeded).toHaveBeenCalledWith( - 'alpaca-oauth-account-1', + 'alpaca-oauth-credential-1', 'user-1', expect.any(String) ) @@ -622,7 +622,7 @@ describe('Trading provider order route', () => { request: expect.objectContaining({ accountId: 'ACC-1', clientOrderId, - tokenAccountId: 'alpaca-oauth-account-1', + credentialId: 'alpaca-oauth-credential-1', serviceId: 'alpaca-live', orderType: 'market', quantity: 3, @@ -771,7 +771,7 @@ describe('Trading provider order route', () => { request: expect.objectContaining({ accountId: 'ACC-1', clientOrderId, - tokenAccountId: 'tradier-oauth-account-1', + credentialId: 'tradier-oauth-credential-1', serviceId: 'tradier-live', quantity: 3, side: 'buy', diff --git a/apps/tradinggoose/app/api/providers/trading/order/route.ts b/apps/tradinggoose/app/api/providers/trading/order/route.ts index 0196a6df9..edf730fcb 100644 --- a/apps/tradinggoose/app/api/providers/trading/order/route.ts +++ b/apps/tradinggoose/app/api/providers/trading/order/route.ts @@ -21,7 +21,7 @@ const orderListingSchema = z const portfolioIdentitySchema = z .object({ providerId: nonEmptyStringSchema, - tokenAccountId: nonEmptyStringSchema, + credentialId: nonEmptyStringSchema, serviceId: nonEmptyStringSchema, accountId: nonEmptyStringSchema, }) diff --git a/apps/tradinggoose/app/api/providers/trading/portfolio-identities/route.test.ts b/apps/tradinggoose/app/api/providers/trading/portfolio-identities/route.test.ts index 1de13b86b..90ebfa4d8 100644 --- a/apps/tradinggoose/app/api/providers/trading/portfolio-identities/route.test.ts +++ b/apps/tradinggoose/app/api/providers/trading/portfolio-identities/route.test.ts @@ -66,7 +66,7 @@ describe('trading portfolio identities route', () => { { providerId: 'alpaca', providerName: 'Alpaca', - tokenAccountId: 'oauth-account-1', + credentialId: 'oauth-credential-1', serviceId: 'alpaca-live', accountId: 'account-1', accountName: 'Main', @@ -94,7 +94,7 @@ describe('trading portfolio identities route', () => { label: 'Main', rightLabel: 'Alpaca Live - cash - active - USD', value: { - tokenAccountId: 'oauth-account-1', + credentialId: 'oauth-credential-1', accountId: 'account-1', }, }, diff --git a/apps/tradinggoose/app/api/tools/trading/order-history/route.test.ts b/apps/tradinggoose/app/api/tools/trading/order-history/route.test.ts index e85032a03..e93704428 100644 --- a/apps/tradinggoose/app/api/tools/trading/order-history/route.test.ts +++ b/apps/tradinggoose/app/api/tools/trading/order-history/route.test.ts @@ -82,7 +82,7 @@ describe('order history support route', () => { listingIdentity: { listing_id: 'AAPL', listing_type: 'stock' }, request: { accountId: 'account-1', - tokenAccountId: 'oauth-account-1', + credentialId: 'oauth-account-1', serviceId: 'alpaca-paper', quantity: 1, side: 'buy', @@ -107,7 +107,7 @@ describe('order history support route', () => { history: [ expect.not.objectContaining({ accountId: 'account-1', - tokenAccountId: 'oauth-account-1', + credentialId: 'oauth-account-1', serviceId: 'alpaca-paper', }), ], diff --git a/apps/tradinggoose/app/api/tools/trading/holdings/route.ts b/apps/tradinggoose/app/api/tools/trading/portfolio-detail/route.ts similarity index 73% rename from apps/tradinggoose/app/api/tools/trading/holdings/route.ts rename to apps/tradinggoose/app/api/tools/trading/portfolio-detail/route.ts index b45a188fa..5905139ec 100644 --- a/apps/tradinggoose/app/api/tools/trading/holdings/route.ts +++ b/apps/tradinggoose/app/api/tools/trading/portfolio-detail/route.ts @@ -2,10 +2,13 @@ import { type NextRequest, NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { createLogger } from '@/lib/logs/console/logger' import { isTradingServiceError } from '@/lib/trading/errors' -import { getTradingHoldings, type TradingHoldingsRequest } from '@/lib/trading/holdings' +import { + getTradingPortfolioDetail, + type TradingPortfolioDetailRequest, +} from '@/lib/trading/portfolio-detail' import { generateRequestId } from '@/lib/utils' -const logger = createLogger('TradingHoldingsAPI') +const logger = createLogger('TradingPortfolioDetailAPI') export const dynamic = 'force-dynamic' export const runtime = 'nodejs' @@ -22,9 +25,9 @@ export async function POST(request: NextRequest) { ) } - let body: TradingHoldingsRequest + let body: TradingPortfolioDetailRequest try { - body = (await request.json()) as TradingHoldingsRequest + body = (await request.json()) as TradingPortfolioDetailRequest } catch { return NextResponse.json( { success: false, error: { message: 'Invalid JSON in request body' } }, @@ -48,16 +51,19 @@ export async function POST(request: NextRequest) { ) } - const holdings = await getTradingHoldings({ - requestData: body, + const portfolioDetail = await getTradingPortfolioDetail({ + requestData: { + ...body, + workspaceId: body.workspaceId ?? workspaceId, + }, requestId, userId: auth.userId, }) - return NextResponse.json({ success: true, data: holdings }, { status: 200 }) + return NextResponse.json({ success: true, data: portfolioDetail }, { status: 200 }) } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to fetch holdings' - logger.error(`[${requestId}] Failed to fetch holdings`, { error: message }) + const message = error instanceof Error ? error.message : 'Failed to fetch portfolio detail' + logger.error(`[${requestId}] Failed to fetch portfolio detail`, { error: message }) return NextResponse.json( { success: false, error: { message } }, { status: isTradingServiceError(error) ? error.status : 500 } diff --git a/apps/tradinggoose/app/api/v1/logs/filters.ts b/apps/tradinggoose/app/api/v1/logs/filters.ts index 81056c292..43343b652 100644 --- a/apps/tradinggoose/app/api/v1/logs/filters.ts +++ b/apps/tradinggoose/app/api/v1/logs/filters.ts @@ -21,7 +21,7 @@ export interface LogFilters { indicatorId?: string providerId?: string interval?: string - triggerSource?: 'indicator_trigger' + triggerSource?: string cursor?: { startedAt: string id: string @@ -150,9 +150,16 @@ export function buildLogFilters(filters: LogFilters): SQL { sql`${workflowExecutionLogs.executionData}->'trigger'->'data'->'monitor'->>'interval' = ${filters.interval}` ) } - if (filters.triggerSource) { + const triggerSources = (filters.triggerSource ?? '') + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + if (triggerSources.length > 0) { conditions.push( - sql`${workflowExecutionLogs.executionData}->'trigger'->>'source' = ${filters.triggerSource}` + inArray( + sql`${workflowExecutionLogs.executionData}->'trigger'->>'source'`, + triggerSources + ) ) } diff --git a/apps/tradinggoose/app/api/v1/logs/route.ts b/apps/tradinggoose/app/api/v1/logs/route.ts index 93c701323..08e8b0eed 100644 --- a/apps/tradinggoose/app/api/v1/logs/route.ts +++ b/apps/tradinggoose/app/api/v1/logs/route.ts @@ -4,6 +4,7 @@ import { and, eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { createLogger } from '@/lib/logs/console/logger' +import { isMonitorTriggerId } from '@/lib/monitors/sources' import { buildWorkspaceAccessScope } from '@/lib/permissions/utils' import { normalizeOptionalString } from '@/lib/utils' import { parseListingFilter } from '@/app/api/logs/log-utils' @@ -16,6 +17,20 @@ const logger = createLogger('V1LogsAPI') export const dynamic = 'force-dynamic' export const revalidate = 0 +const splitCsv = (value: string | undefined) => + (value ?? '') + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + +const MonitorTriggerSourceParamSchema = z + .preprocess((value) => { + if (typeof value !== 'string') return value + const trimmed = value.trim() + return trimmed.length === 0 ? undefined : trimmed + }, z.string().optional()) + .refine((value) => !value || splitCsv(value).every(isMonitorTriggerId), 'Invalid triggerSource') + const QueryParamsSchema = z.object({ workspaceId: z.string(), workflowIds: z.string().optional(), @@ -38,11 +53,7 @@ const QueryParamsSchema = z.object({ indicatorId: z.string().optional(), providerId: z.string().optional(), interval: z.string().optional(), - triggerSource: z.preprocess((value) => { - if (typeof value !== 'string') return value - const trimmed = value.trim() - return trimmed.length === 0 ? undefined : trimmed - }, z.literal('indicator_trigger').optional()), + triggerSource: MonitorTriggerSourceParamSchema, limit: z.coerce.number().optional().default(100), cursor: z.string().optional(), order: z.enum(['desc', 'asc']).optional().default('desc'), diff --git a/apps/tradinggoose/app/api/webhooks/[id]/route.ts b/apps/tradinggoose/app/api/webhooks/[id]/route.ts index 057dab285..b1d74700d 100644 --- a/apps/tradinggoose/app/api/webhooks/[id]/route.ts +++ b/apps/tradinggoose/app/api/webhooks/[id]/route.ts @@ -5,6 +5,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { getOAuthAccessTokenForUserCredential } from '@/lib/credentials/oauth' import { createLogger } from '@/lib/logs/console/logger' +import { isMonitorProvider } from '@/lib/monitors/sources' import { getUserEntityPermissions } from '@/lib/permissions/utils' import { generateRequestId } from '@/lib/utils' @@ -48,8 +49,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ const webhookData = webhooks[0] - if (webhookData.webhook.provider === 'indicator') { - logger.warn(`[${requestId}] Generic webhook read blocked for indicator webhook: ${id}`) + if (isMonitorProvider(webhookData.webhook.provider)) { + logger.warn(`[${requestId}] Generic webhook read blocked for monitor webhook: ${id}`) return NextResponse.json({ error: 'Webhook not found' }, { status: 404 }) } @@ -103,8 +104,8 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< const body = await request.json() const { path, provider, providerConfig, isActive } = body - if (provider === 'indicator') { - logger.warn(`[${requestId}] Generic webhook update cannot set indicator provider: ${id}`) + if (isMonitorProvider(provider)) { + logger.warn(`[${requestId}] Generic webhook update cannot set monitor provider: ${id}`) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } @@ -130,8 +131,8 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< const webhookData = webhooks[0] - if (webhookData.webhook.provider === 'indicator') { - logger.warn(`[${requestId}] Generic webhook update blocked for indicator webhook: ${id}`) + if (isMonitorProvider(webhookData.webhook.provider)) { + logger.warn(`[${requestId}] Generic webhook update blocked for monitor webhook: ${id}`) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } @@ -230,8 +231,8 @@ export async function DELETE( const webhookData = webhooks[0] - if (webhookData.webhook.provider === 'indicator') { - logger.warn(`[${requestId}] Generic webhook delete blocked for indicator webhook: ${id}`) + if (isMonitorProvider(webhookData.webhook.provider)) { + logger.warn(`[${requestId}] Generic webhook delete blocked for monitor webhook: ${id}`) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } diff --git a/apps/tradinggoose/app/api/webhooks/[id]/test-url/route.ts b/apps/tradinggoose/app/api/webhooks/[id]/test-url/route.ts index 27308f6a7..32892ee48 100644 --- a/apps/tradinggoose/app/api/webhooks/[id]/test-url/route.ts +++ b/apps/tradinggoose/app/api/webhooks/[id]/test-url/route.ts @@ -3,6 +3,7 @@ import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' +import { isMonitorProvider } from '@/lib/monitors/sources' import { getUserEntityPermissions } from '@/lib/permissions/utils' import { getBaseUrl } from '@/lib/urls/utils' import { generateRequestId } from '@/lib/utils' @@ -46,8 +47,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Webhook not found' }, { status: 404 }) } - if (rows[0].webhook.provider === 'indicator') { - logger.warn(`[${requestId}] Denied test-url mint for indicator webhook ${id}`) + if (isMonitorProvider(rows[0].webhook.provider)) { + logger.warn(`[${requestId}] Denied test-url mint for monitor webhook ${id}`) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } diff --git a/apps/tradinggoose/app/api/webhooks/route.ts b/apps/tradinggoose/app/api/webhooks/route.ts index f1bf99917..5cb215214 100644 --- a/apps/tradinggoose/app/api/webhooks/route.ts +++ b/apps/tradinggoose/app/api/webhooks/route.ts @@ -1,6 +1,6 @@ import { db } from '@tradinggoose/db' import { webhook, workflow } from '@tradinggoose/db/schema' -import { and, desc, eq, ne } from 'drizzle-orm' +import { and, desc, eq, notInArray } from 'drizzle-orm' import { nanoid } from 'nanoid' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' @@ -9,6 +9,7 @@ import { resolveOAuthCredentialAccountForUser, } from '@/lib/credentials/oauth' import { createLogger } from '@/lib/logs/console/logger' +import { isMonitorProvider, MONITOR_WEBHOOK_PROVIDERS } from '@/lib/monitors/sources' import { getUserEntityPermissions } from '@/lib/permissions/utils' import { getBaseUrl } from '@/lib/urls/utils' import { generateRequestId } from '@/lib/utils' @@ -17,6 +18,9 @@ const logger = createLogger('WebhooksAPI') export const dynamic = 'force-dynamic' +const nonMonitorWebhookCondition = () => + notInArray(webhook.provider, [...MONITOR_WEBHOOK_PROVIDERS]) + // Get all webhooks for the current user export async function GET(request: NextRequest) { const requestId = generateRequestId() @@ -79,7 +83,7 @@ export async function GET(request: NextRequest) { and( eq(webhook.workflowId, workflowId), eq(webhook.blockId, blockId), - ne(webhook.provider, 'indicator') + nonMonitorWebhookCondition() ) ) .orderBy(desc(webhook.updatedAt)) @@ -107,7 +111,7 @@ export async function GET(request: NextRequest) { }) .from(webhook) .innerJoin(workflow, eq(webhook.workflowId, workflow.id)) - .where(and(eq(workflow.userId, session.user.id), ne(webhook.provider, 'indicator'))) + .where(and(eq(workflow.userId, session.user.id), nonMonitorWebhookCondition())) logger.info(`[${requestId}] Retrieved ${webhooks.length} user-owned webhooks`) return NextResponse.json({ webhooks }, { status: 200 }) @@ -131,8 +135,8 @@ export async function POST(request: NextRequest) { const body = await request.json() const { workflowId, path, provider, providerConfig, blockId } = body - if (provider === 'indicator') { - logger.warn(`[${requestId}] Denied indicator webhook creation through generic webhook API`) + if (isMonitorProvider(provider)) { + logger.warn(`[${requestId}] Denied monitor webhook creation through generic webhook API`) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } @@ -168,7 +172,7 @@ export async function POST(request: NextRequest) { and( eq(webhook.workflowId, workflowId), eq(webhook.blockId, blockId), - ne(webhook.provider, 'indicator') + nonMonitorWebhookCondition() ) ) .limit(1) @@ -268,7 +272,7 @@ export async function POST(request: NextRequest) { and( eq(webhook.workflowId, workflowId), eq(webhook.blockId, blockId), - ne(webhook.provider, 'indicator') + nonMonitorWebhookCondition() ) ) .limit(1) @@ -283,13 +287,10 @@ export async function POST(request: NextRequest) { .where(eq(webhook.path, finalPath)) .limit(1) if (existingByPath.length > 0) { - if (existingByPath[0].provider === 'indicator') { - logger.warn( - `[${requestId}] Generic webhook upsert blocked for indicator path collision`, - { - path: finalPath, - } - ) + if (isMonitorProvider(existingByPath[0].provider)) { + logger.warn(`[${requestId}] Generic webhook upsert blocked for monitor path collision`, { + path: finalPath, + }) return NextResponse.json( { error: 'Webhook path already exists.', code: 'PATH_EXISTS' }, { status: 409 } @@ -329,10 +330,10 @@ export async function POST(request: NextRequest) { isActive: true, updatedAt: new Date(), }) - .where(and(eq(webhook.id, targetWebhookId), ne(webhook.provider, 'indicator'))) + .where(and(eq(webhook.id, targetWebhookId), nonMonitorWebhookCondition())) .returning() if (updatedResult.length === 0) { - logger.warn(`[${requestId}] Generic webhook update blocked for indicator target`, { + logger.warn(`[${requestId}] Generic webhook update blocked for monitor target`, { webhookId: targetWebhookId, }) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) diff --git a/apps/tradinggoose/app/api/webhooks/test/[id]/route.ts b/apps/tradinggoose/app/api/webhooks/test/[id]/route.ts index 745fa967c..457f04675 100644 --- a/apps/tradinggoose/app/api/webhooks/test/[id]/route.ts +++ b/apps/tradinggoose/app/api/webhooks/test/[id]/route.ts @@ -1,10 +1,10 @@ import { type NextRequest, NextResponse } from 'next/server' import { createLogger } from '@/lib/logs/console/logger' +import { isMonitorProvider } from '@/lib/monitors/sources' import { generateRequestId } from '@/lib/utils' import { findWebhookAndWorkflow, handleProviderChallenges, - mapDispatchGateResultToHttpResponse, parseWebhookBody, queueWebhookExecution, verifyProviderAuth, @@ -44,8 +44,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const { webhook: foundWebhook, workflow: foundWorkflow } = result - if (foundWebhook.provider === 'indicator') { - logger.warn(`[${requestId}] Blocked external test-receiver request for indicator webhook`, { + if (isMonitorProvider(foundWebhook.provider)) { + logger.warn(`[${requestId}] Blocked external test-receiver request for monitor webhook`, { webhookId: foundWebhook.id, }) return new NextResponse('Forbidden', { status: 403 }) @@ -72,16 +72,10 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ `[${requestId}] Executing TEST webhook for ${foundWebhook.provider} (workflow: ${foundWorkflow.id})` ) - return queueWebhookExecution( - foundWebhook, - foundWorkflow, - body, - request, - { - requestId, - path: foundWebhook.path, - testMode: true, - executionTarget: 'live', - } - ) + return queueWebhookExecution(foundWebhook, foundWorkflow, body, request, { + requestId, + path: foundWebhook.path, + testMode: true, + executionTarget: 'live', + }) } diff --git a/apps/tradinggoose/app/api/webhooks/test/route.ts b/apps/tradinggoose/app/api/webhooks/test/route.ts index f926aa2be..58cce9ac4 100644 --- a/apps/tradinggoose/app/api/webhooks/test/route.ts +++ b/apps/tradinggoose/app/api/webhooks/test/route.ts @@ -4,6 +4,7 @@ import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' +import { isMonitorProvider } from '@/lib/monitors/sources' import { getUserEntityPermissions } from '@/lib/permissions/utils' import { getBaseUrl } from '@/lib/urls/utils' import { generateRequestId } from '@/lib/utils' @@ -70,8 +71,8 @@ export async function GET(request: NextRequest) { return NextResponse.json({ success: false, error: 'Forbidden' }, { status: 403 }) } - if (foundWebhook.provider === 'indicator') { - logger.warn(`[${requestId}] Blocked webhook test helper call for indicator webhook`, { + if (isMonitorProvider(foundWebhook.provider)) { + logger.warn(`[${requestId}] Blocked webhook test helper call for monitor webhook`, { webhookId, }) return NextResponse.json({ success: false, error: 'Forbidden' }, { status: 403 }) diff --git a/apps/tradinggoose/app/api/webhooks/trigger/[path]/route.ts b/apps/tradinggoose/app/api/webhooks/trigger/[path]/route.ts index a803b81e9..7026bdbf0 100644 --- a/apps/tradinggoose/app/api/webhooks/trigger/[path]/route.ts +++ b/apps/tradinggoose/app/api/webhooks/trigger/[path]/route.ts @@ -1,5 +1,6 @@ import { type NextRequest, NextResponse } from 'next/server' import { createLogger } from '@/lib/logs/console/logger' +import { isMonitorProvider } from '@/lib/monitors/sources' import { generateRequestId } from '@/lib/utils' import { checkUsageLimits, @@ -30,8 +31,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ const { webhook: foundWebhook } = findResult - if (foundWebhook.provider === 'indicator') { - logger.warn(`[${requestId}] Blocked external trigger request for indicator webhook`, { + if (isMonitorProvider(foundWebhook.provider)) { + logger.warn(`[${requestId}] Blocked external trigger request for monitor webhook`, { path, webhookId: foundWebhook.id, }) @@ -72,8 +73,8 @@ export async function POST( const { webhook: foundWebhook, workflow: foundWorkflow } = findResult - if (foundWebhook.provider === 'indicator') { - logger.warn(`[${requestId}] Blocked external trigger request for indicator webhook`, { + if (isMonitorProvider(foundWebhook.provider)) { + logger.warn(`[${requestId}] Blocked external trigger request for monitor webhook`, { path, webhookId: foundWebhook.id, }) @@ -130,16 +131,10 @@ export async function POST( } } - return queueWebhookExecution( - foundWebhook, - foundWorkflow, - body, - request, - { - requestId, - path, - testMode: false, - executionTarget: 'deployed', - } - ) + return queueWebhookExecution(foundWebhook, foundWorkflow, body, request, { + requestId, + path, + testMode: false, + executionTarget: 'deployed', + }) } diff --git a/apps/tradinggoose/app/api/workflows/[id]/deploy/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/deploy/route.test.ts index b1090b9a4..cf6145158 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/deploy/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/deploy/route.test.ts @@ -31,8 +31,7 @@ describe('Workflow Deploy API Route', () => { })) vi.doMock('@/lib/workflows/utils', () => ({ - validateWorkflowPermissions: (...args: unknown[]) => - mockValidateWorkflowPermissions(...args), + validateWorkflowPermissions: (...args: unknown[]) => mockValidateWorkflowPermissions(...args), hasWorkflowChanged: vi.fn().mockReturnValue(false), })) @@ -46,12 +45,12 @@ describe('Workflow Deploy API Route', () => { removePublishedChatsForWorkflowTx: vi.fn(), })) - vi.doMock('@/app/api/indicator-monitors/reconcile', () => ({ - notifyIndicatorMonitorsReconcile: vi.fn().mockResolvedValue(undefined), + vi.doMock('@/app/api/monitors/reconcile', () => ({ + notifyMonitorsReconcile: vi.fn().mockResolvedValue(undefined), })) - vi.doMock('@/app/api/indicator-monitors/shared', () => ({ - pauseMonitorsMissingDeployedIndicatorTrigger: vi.fn().mockResolvedValue(undefined), + vi.doMock('@/app/api/monitors/shared', () => ({ + pauseMonitorsMissingDeployedTrigger: vi.fn().mockResolvedValue(undefined), })) vi.doMock('@/app/api/workflows/utils', () => ({ diff --git a/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts b/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts index 4d0fd3353..851a54a5c 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts @@ -9,8 +9,8 @@ import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' import { deployWorkflow, loadWorkflowState } from '@/lib/workflows/db-helpers' import { hasWorkflowChanged, validateWorkflowPermissions } from '@/lib/workflows/utils' -import { notifyIndicatorMonitorsReconcile } from '@/app/api/indicator-monitors/reconcile' -import { pauseMonitorsMissingDeployedIndicatorTrigger } from '@/app/api/indicator-monitors/shared' +import { notifyMonitorsReconcile } from '@/app/api/monitors/reconcile' +import { pauseMonitorsMissingDeployedTrigger } from '@/app/api/monitors/shared' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' const logger = createLogger('WorkflowDeployAPI') @@ -272,8 +272,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ logger.info(`[${requestId}] Workflow deployed successfully: ${id}`) - await pauseMonitorsMissingDeployedIndicatorTrigger(id) - await notifyIndicatorMonitorsReconcile({ requestId, logger }) + await pauseMonitorsMissingDeployedTrigger(id) + await notifyMonitorsReconcile({ requestId, logger }) const responseApiKeyInfo = keyInfo ? `${keyInfo.name} (${keyInfo.type})` : 'Default key' @@ -331,7 +331,7 @@ export async function DELETE( logger.info(`[${requestId}] Workflow undeployed successfully: ${id}`) - await notifyIndicatorMonitorsReconcile({ requestId, logger }) + await notifyMonitorsReconcile({ requestId, logger }) // Track workflow undeployment try { diff --git a/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/activate/route.ts b/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/activate/route.ts index 46430b79e..ec75ce036 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/activate/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/activate/route.ts @@ -5,8 +5,8 @@ import { reconcilePublishedChatsForDeploymentTx } from '@/lib/chat/published-dep import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' import { validateWorkflowPermissions } from '@/lib/workflows/utils' -import { notifyIndicatorMonitorsReconcile } from '@/app/api/indicator-monitors/reconcile' -import { pauseMonitorsMissingDeployedIndicatorTrigger } from '@/app/api/indicator-monitors/shared' +import { notifyMonitorsReconcile } from '@/app/api/monitors/reconcile' +import { pauseMonitorsMissingDeployedTrigger } from '@/app/api/monitors/shared' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' const logger = createLogger('WorkflowActivateDeploymentAPI') @@ -135,8 +135,8 @@ export async function POST( }) }) - await pauseMonitorsMissingDeployedIndicatorTrigger(id) - await notifyIndicatorMonitorsReconcile({ requestId, logger }) + await pauseMonitorsMissingDeployedTrigger(id) + await notifyMonitorsReconcile({ requestId, logger }) return createSuccessResponse({ success: true, deployedAt: now }) } catch (error: any) { diff --git a/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.test.ts index a4252798d..a9e244307 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.test.ts @@ -127,20 +127,16 @@ describe('Revert To Deployment Version API Route', () => { })) vi.doMock('@/app/api/workflows/utils', () => ({ - createErrorResponse: vi.fn((error, status) => - Response.json({ error }, { status }) - ), - createSuccessResponse: vi.fn((data) => - Response.json({ data }, { status: 200 }) - ), + createErrorResponse: vi.fn((error, status) => Response.json({ error }, { status })), + createSuccessResponse: vi.fn((data) => Response.json({ data }, { status: 200 })), })) - vi.doMock('@/app/api/indicator-monitors/reconcile', () => ({ - notifyIndicatorMonitorsReconcile: vi.fn().mockResolvedValue(undefined), + vi.doMock('@/app/api/monitors/reconcile', () => ({ + notifyMonitorsReconcile: vi.fn().mockResolvedValue(undefined), })) - vi.doMock('@/app/api/indicator-monitors/shared', () => ({ - pauseMonitorsMissingDeployedIndicatorTrigger: vi.fn().mockResolvedValue(undefined), + vi.doMock('@/app/api/monitors/shared', () => ({ + pauseMonitorsMissingDeployedTrigger: vi.fn().mockResolvedValue(undefined), })) }) diff --git a/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.ts b/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.ts index 3cfd18435..b249490aa 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.ts @@ -7,9 +7,9 @@ import { saveWorkflowToNormalizedTables } from '@/lib/workflows/db-helpers' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { tryApplyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' import { createWorkflowSnapshot } from '@/lib/yjs/workflow-session' +import { notifyMonitorsReconcile } from '@/app/api/monitors/reconcile' +import { pauseMonitorsMissingDeployedTrigger } from '@/app/api/monitors/shared' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' -import { notifyIndicatorMonitorsReconcile } from '@/app/api/indicator-monitors/reconcile' -import { pauseMonitorsMissingDeployedIndicatorTrigger } from '@/app/api/indicator-monitors/shared' const logger = createLogger('RevertToDeploymentVersionAPI') @@ -118,8 +118,8 @@ export async function POST( // Publish the reverted state to Yjs only after the durable writes succeed. await tryApplyWorkflowState(id, revertSnapshot, revertVariables) - await pauseMonitorsMissingDeployedIndicatorTrigger(id) - await notifyIndicatorMonitorsReconcile({ requestId, logger }) + await pauseMonitorsMissingDeployedTrigger(id) + await notifyMonitorsReconcile({ requestId, logger }) return createSuccessResponse({ message: 'Reverted to deployment version', diff --git a/apps/tradinggoose/app/globals.css b/apps/tradinggoose/app/globals.css index b0dadf3e6..8a0e26e99 100644 --- a/apps/tradinggoose/app/globals.css +++ b/apps/tradinggoose/app/globals.css @@ -73,7 +73,7 @@ --foreground: 20 14.3% 4.1%; /* Card Colors */ - --card: 0 0% 99.5%; + --card: 0 0% 98.5%; --card-foreground: 20 14.3% 4.1%; /* Popover Colors */ diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/board/board-state.test.ts b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/board/board-state.test.ts index 7f6d143bf..12f8a0acc 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/board/board-state.test.ts +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/board/board-state.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' -import { buildMonitorBoardSections } from './board-state' import type { MonitorExecutionItem } from '../data/execution-ordering' import { DEFAULT_EXECUTION_MONITOR_VIEW_CONFIG } from '../view/view-config' +import { buildMonitorBoardSections } from './board-state' const buildExecution = (overrides: Partial): MonitorExecutionItem => ({ logId: 'log-1', @@ -15,7 +15,10 @@ const buildExecution = (overrides: Partial): MonitorExecut workflowName: 'Workflow One', workflowColor: '#3972F6', monitorId: 'monitor-1', + source: 'indicator', providerId: 'alpaca', + serviceId: null, + accountId: null, interval: '1m', indicatorId: 'rsi', assetType: 'stock', diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/board/kanban.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/board/kanban.tsx index e738ba90c..564330eea 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/board/kanban.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/board/kanban.tsx @@ -632,7 +632,7 @@ export function KanbanCards({
{ workflowName: 'Workflow One', workflowColor: '#3972F6', monitorId: 'monitor-1', + + source: 'indicator', + providerId: 'alpaca', + + serviceId: null, + + accountId: null, + interval: '1m', indicatorId: 'rsi', assetType: 'stock', diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/board/monitor-board.test.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/board/monitor-board.test.tsx index f128b681f..b9d87192c 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/board/monitor-board.test.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/board/monitor-board.test.tsx @@ -98,7 +98,15 @@ describe('MonitorBoard', () => { workflowName: 'Workflow One', workflowColor: '#3972F6', monitorId: 'monitor-1', + + source: 'indicator', + providerId: 'alpaca', + + serviceId: null, + + accountId: null, + interval: '1m', indicatorId: 'rsi', assetType: 'stock', @@ -189,7 +197,15 @@ describe('MonitorBoard', () => { workflowName: 'Workflow One', workflowColor: '#3972F6', monitorId: 'monitor-1', + + source: 'indicator', + providerId: 'alpaca', + + serviceId: null, + + accountId: null, + interval: '1m', indicatorId: 'rsi', assetType: 'stock', diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/board/monitor-board.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/board/monitor-board.tsx index 62813f7b9..456a9cfa2 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/board/monitor-board.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/board/monitor-board.tsx @@ -204,18 +204,7 @@ export function MonitorBoard({ > {sections.map((section) => ( - sum + column.totalCount, 0)} executions`} - actions={ - !canReorder ? ( - - Sorted - - ) : null - } - > + {section.columns.map((column) => { const canDrop = canReorder && dragState?.columnId === column.id diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/board/monitor-kanban.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/board/monitor-kanban.tsx index 617967fe3..ceb64ee81 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/board/monitor-kanban.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/board/monitor-kanban.tsx @@ -4,11 +4,7 @@ import type { ComponentProps, ReactNode } from 'react' import { Badge, type BadgeProps } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' -import { - MonitorAggregateBadges, - MonitorBoardShell, - MonitorSectionHeader, -} from '../shared/monitor-ui' +import { MonitorAggregateBadges, MonitorBoardShell } from '../shared/monitor-ui' import { KanbanBoard, KanbanCard, KanbanCards } from './kanban' type MonitorKanbanShellProps = ComponentProps @@ -17,40 +13,14 @@ export function MonitorKanbanShell(props: MonitorKanbanShellProps) { return } -type MonitorKanbanSectionProps = ComponentProps<'section'> & { - actions?: ReactNode - aggregateBadgeClassName?: string - aggregateVariant?: BadgeProps['variant'] - aggregates?: Record - description?: ReactNode - title: ReactNode -} +type MonitorKanbanSectionProps = ComponentProps<'section'> -export function MonitorKanbanSection({ - actions, - aggregateBadgeClassName, - aggregateVariant, - aggregates = {}, - children, - className, - description, - title, - ...props -}: MonitorKanbanSectionProps) { +export function MonitorKanbanSection({ children, className, ...props }: MonitorKanbanSectionProps) { return (
- - {actions ?? ( - - )} - {children}
) diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/config/config-board-state.ts b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/config/config-board-state.ts index 1093a7bf1..33956f7f8 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/config/config-board-state.ts +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/config/config-board-state.ts @@ -48,10 +48,7 @@ type ConfigBoardGroup = { export type ConfigBoardSection = { id: string - label: string groups: ConfigBoardGroup[] - cards: ConfigMonitorCard[] - aggregates: ConfigBoardAggregates } const STATUS_VALUES: Array<{ id: ConfigMonitorStatus; label: string }> = [ @@ -122,7 +119,7 @@ const buildAxisValues = ( }) ) } else if (field === 'provider') { - referenceData.streamingProviders.forEach((provider) => + referenceData.marketProviders.concat(referenceData.tradingProviders).forEach((provider) => addAxisValue(values, { id: provider.id, label: provider.name, @@ -171,7 +168,7 @@ export const buildConfigBoardSections = ( ): ConfigBoardSection[] => { const sectionValues = config.sliceBy ? buildAxisValues(config.sliceBy, cards, referenceData) - : [{ ...ALL_AXIS_VALUE, label: 'All monitors', sortValue: 'All monitors' }] + : [ALL_AXIS_VALUE] const groupValues = buildAxisValues(config.groupBy, cards, referenceData) const verticalValues = config.verticalGroupBy ? buildAxisValues(config.verticalGroupBy, cards, referenceData) @@ -230,10 +227,7 @@ export const buildConfigBoardSections = ( return { id: sectionValue.id, - label: sectionValue.label, groups, - cards: sectionCards, - aggregates: aggregateCards(sectionCards, config.fieldSums), } satisfies ConfigBoardSection }) } diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/config/config-card-model.ts b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/config/config-card-model.ts index 8f17b6d70..847bc04db 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/config/config-card-model.ts +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/config/config-card-model.ts @@ -1,12 +1,14 @@ import type { ListingIdentity } from '@/lib/listing/identity' +import { PORTFOLIO_MONITOR_PROVIDER } from '@/lib/monitors/sources' import type { MonitorExecutionOutcome } from '../data/execution-ordering' import type { MonitorExecutionSummary } from '../data/use-monitor-execution-summaries' -import type { IndicatorMonitorRecord, MonitorReferenceData } from '../shared/types' +import type { MonitorRecord, MonitorReferenceData } from '../shared/types' import type { ConfigMonitorDimensionField, ConfigMonitorStatus } from '../view/view-config' import { canonicalizeListingValue } from './config-filter-values' export type ConfigMonitorCard = { monitorId: string + source: MonitorRecord['source'] workflowId: string blockId: string workflowTargetKey: string @@ -17,7 +19,7 @@ export type ConfigMonitorCard = { providerId: string providerLabel: string interval: string - listing: ListingIdentity + listing: ListingIdentity | null listingValue: string listingLabel: string isActive: boolean @@ -25,12 +27,12 @@ export type ConfigMonitorCard = { createdAt: string updatedAt: string indicatorInputs: Record - auth: IndicatorMonitorRecord['providerConfig']['monitor']['auth'] - providerParams: IndicatorMonitorRecord['providerConfig']['monitor']['providerParams'] + auth: MonitorRecord['providerConfig']['monitor']['auth'] + providerParams: MonitorRecord['providerConfig']['monitor']['providerParams'] lastExecutionAt: string | null lastOutcome: MonitorExecutionOutcome | null lastExecutionLogId: string | null - sourceMonitor: IndicatorMonitorRecord + sourceMonitor: MonitorRecord } export type ConfigAxisValue = { @@ -76,34 +78,46 @@ const getSummaryFields = (summary: MonitorExecutionSummary | undefined) => ({ }) export const buildConfigMonitorCards = ( - monitors: IndicatorMonitorRecord[], + monitors: MonitorRecord[], referenceData: MonitorReferenceData, summariesByMonitorId: Record ): ConfigMonitorCard[] => monitors.map((monitor) => { const monitorConfig = monitor.providerConfig.monitor + const isPortfolio = monitor.source === PORTFOLIO_MONITOR_PROVIDER const workflowTargetKey = readWorkflowTargetKey(monitor.workflowId, monitor.blockId) const workflowTarget = referenceData.workflowTargetByKey[workflowTargetKey] - const indicator = referenceData.indicatorById[monitorConfig.indicatorId] - const provider = referenceData.providerById[monitorConfig.providerId] - const listingValue = canonicalizeListingValue(monitorConfig.listing) ?? '' + const indicator = monitorConfig.indicatorId + ? referenceData.indicatorById[monitorConfig.indicatorId] + : undefined + const provider = isPortfolio + ? referenceData.tradingProviderById[monitorConfig.providerId] + : referenceData.marketProviderById[monitorConfig.providerId] + const listingValue = isPortfolio + ? `portfolio:${monitorConfig.serviceId ?? ''}:${monitorConfig.accountId ?? ''}` + : (canonicalizeListingValue(monitorConfig.listing) ?? '') const summary = getSummaryFields(summariesByMonitorId[monitor.monitorId]) return { monitorId: monitor.monitorId, + source: monitor.source, workflowId: monitor.workflowId, blockId: monitor.blockId, workflowTargetKey, workflowName: workflowTarget?.workflowName ?? monitor.workflowId, workflowTargetLabel: workflowTarget?.label ?? workflowTargetKey, - indicatorId: monitorConfig.indicatorId, - indicatorName: indicator?.name ?? monitorConfig.indicatorId, + indicatorId: monitorConfig.indicatorId ?? 'portfolio_state', + indicatorName: isPortfolio + ? 'Portfolio state' + : (indicator?.name ?? monitorConfig.indicatorId ?? 'Indicator'), providerId: monitorConfig.providerId, providerLabel: provider?.name ?? monitorConfig.providerId, - interval: monitorConfig.interval, - listing: monitorConfig.listing, + interval: monitorConfig.interval ?? `${monitorConfig.pollIntervalSeconds ?? 60}s poll`, + listing: monitorConfig.listing ?? null, listingValue, - listingLabel: formatListingLabel(monitorConfig.listing), + listingLabel: isPortfolio + ? (monitorConfig.accountId ?? 'Portfolio account') + : formatListingLabel(monitorConfig.listing), isActive: monitor.isActive, status: monitor.isActive ? 'active' : 'paused', createdAt: monitor.createdAt, diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/config/config-domain.test.ts b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/config/config-domain.test.ts index 96b1a273b..f5110ed97 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/config/config-domain.test.ts +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/config/config-domain.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { IndicatorMonitorRecord, MonitorReferenceData } from '../shared/types' +import type { MonitorRecord, MonitorReferenceData } from '../shared/types' import { DEFAULT_CONFIG_MONITOR_VIEW_CONFIG } from '../view/view-config' import { buildConfigBoardSections } from './config-board-state' import { buildConfigMonitorCards } from './config-card-model' @@ -16,6 +16,8 @@ import { buildConfigSearchSuggestionSet } from './config-search' const referenceData: MonitorReferenceData = { workflowTargets: [ { + source: 'indicator', + triggerId: 'indicator_trigger', workflowId: 'workflow-1', blockId: 'block-1', workflowName: 'Workflow One', @@ -27,6 +29,8 @@ const referenceData: MonitorReferenceData = { ], workflowTargetByKey: { 'workflow-1:block-1': { + source: 'indicator', + triggerId: 'indicator_trigger', workflowId: 'workflow-1', blockId: 'block-1', workflowName: 'Workflow One', @@ -37,15 +41,32 @@ const referenceData: MonitorReferenceData = { }, }, workflowOptions: [], + indicatorWorkflowTargets: [ + { + source: 'indicator', + triggerId: 'indicator_trigger', + workflowId: 'workflow-1', + blockId: 'block-1', + workflowName: 'Workflow One', + workflowColor: '#3972F6', + isDeployed: true, + blockName: 'Indicator Trigger', + label: 'Workflow One - Indicator Trigger', + }, + ], + portfolioWorkflowTargets: [], indicatorOptions: [{ id: 'rsi', name: 'RSI', source: 'default', color: '#3972F6' }], indicatorById: { rsi: { id: 'rsi', name: 'RSI', source: 'default', color: '#3972F6' }, }, - streamingProviders: [{ id: 'alpaca', name: 'Alpaca' }], - providerById: { alpaca: { id: 'alpaca', name: 'Alpaca' } }, + marketProviders: [{ id: 'alpaca', name: 'Alpaca' }], + marketProviderById: { alpaca: { id: 'alpaca', name: 'Alpaca' } }, providerIntervalsByProviderId: { alpaca: ['1m'] }, providerParamDefinitionsByProviderId: {}, - defaultDraftProviderId: 'alpaca', + tradingProviders: [{ id: 'alpaca', name: 'Alpaca' }], + tradingProviderById: { alpaca: { id: 'alpaca', name: 'Alpaca' } }, + defaultMarketProviderId: '', + defaultPortfolioProviderId: '', defaultDraftInterval: '1m', createDisabledReason: null, isLoading: false, @@ -54,6 +75,7 @@ const referenceData: MonitorReferenceData = { const monitor = { monitorId: 'monitor-1', + source: 'indicator', workflowId: 'workflow-1', blockId: 'block-1', isActive: true, @@ -70,7 +92,7 @@ const monitor = { }, createdAt: '2026-04-23T00:00:00.000Z', updatedAt: '2026-04-24T00:00:00.000Z', -} satisfies IndicatorMonitorRecord +} satisfies MonitorRecord describe('config monitor domain', () => { it('derives cards with reference labels and nullable summaries', () => { @@ -150,7 +172,7 @@ describe('config monitor domain', () => { ) expect(filtered).toHaveLength(1) - expect(sections[0]?.label).toBe('All monitors') + expect(sections[0]?.id).toBe('all') expect(sections[0]?.groups[0]?.statusLanes[0]?.buckets[0]?.cards[0]?.monitorId).toBe( 'monitor-1' ) @@ -169,7 +191,7 @@ describe('config monitor domain', () => { }) expect(sections).toHaveLength(1) - expect(sections[0]?.label).toBe('All monitors') + expect(sections[0]?.id).toBe('all') expect(sections[0]?.groups).toHaveLength(1) expect(sections[0]?.groups[0]?.label).toBe('Workflow target') expect(sections[0]?.groups[0]?.cards).toEqual([]) @@ -208,7 +230,7 @@ describe('config monitor domain', () => { }) }) - it('falls back to a supported interval when a provider drop changes capabilities', () => { + it('falls back to a supported interval when a provider drop changes interval options', () => { const card = buildConfigMonitorCards([monitor], referenceData, {})[0]! const resolution = resolveConfigBoardContextPatch({ decodedContext: { @@ -224,12 +246,9 @@ describe('config monitor domain', () => { }, referenceData: { ...referenceData, - streamingProviders: [ - ...referenceData.streamingProviders, - { id: 'tradier', name: 'Tradier' }, - ], - providerById: { - ...referenceData.providerById, + marketProviders: [...referenceData.marketProviders, { id: 'tradier', name: 'Tradier' }], + marketProviderById: { + ...referenceData.marketProviderById, tradier: { id: 'tradier', name: 'Tradier' }, }, providerIntervalsByProviderId: { @@ -255,9 +274,9 @@ describe('config monitor domain', () => { const card = buildConfigMonitorCards([monitor], referenceData, {})[0]! const nextReferenceData: MonitorReferenceData = { ...referenceData, - streamingProviders: [...referenceData.streamingProviders, { id: 'tradier', name: 'Tradier' }], - providerById: { - ...referenceData.providerById, + marketProviders: [...referenceData.marketProviders, { id: 'tradier', name: 'Tradier' }], + marketProviderById: { + ...referenceData.marketProviderById, tradier: { id: 'tradier', name: 'Tradier' }, }, providerIntervalsByProviderId: { @@ -310,12 +329,9 @@ describe('config monitor domain', () => { }, referenceData: { ...referenceData, - streamingProviders: [ - ...referenceData.streamingProviders, - { id: 'tradier', name: 'Tradier' }, - ], - providerById: { - ...referenceData.providerById, + marketProviders: [...referenceData.marketProviders, { id: 'tradier', name: 'Tradier' }], + marketProviderById: { + ...referenceData.marketProviderById, tradier: { id: 'tradier', name: 'Tradier' }, }, providerIntervalsByProviderId: { @@ -336,7 +352,7 @@ describe('config monitor domain', () => { }) it('clears provider-bound draft state when a monitor edit changes provider', () => { - const sourceMonitor: IndicatorMonitorRecord = { + const sourceMonitor: MonitorRecord = { ...monitor, providerConfig: { ...monitor.providerConfig, @@ -351,9 +367,9 @@ describe('config monitor domain', () => { } const nextReferenceData: MonitorReferenceData = { ...referenceData, - streamingProviders: [...referenceData.streamingProviders, { id: 'tradier', name: 'Tradier' }], - providerById: { - ...referenceData.providerById, + marketProviders: [...referenceData.marketProviders, { id: 'tradier', name: 'Tradier' }], + marketProviderById: { + ...referenceData.marketProviderById, tradier: { id: 'tradier', name: 'Tradier' }, }, providerIntervalsByProviderId: { @@ -393,9 +409,9 @@ describe('config monitor domain', () => { it('uses the default draft interval when an editor provider change has no interval list', () => { const nextReferenceData: MonitorReferenceData = { ...referenceData, - streamingProviders: [...referenceData.streamingProviders, { id: 'tradier', name: 'Tradier' }], - providerById: { - ...referenceData.providerById, + marketProviders: [...referenceData.marketProviders, { id: 'tradier', name: 'Tradier' }], + marketProviderById: { + ...referenceData.marketProviderById, tradier: { id: 'tradier', name: 'Tradier' }, }, providerIntervalsByProviderId: { @@ -419,7 +435,7 @@ describe('config monitor domain', () => { }) it('builds provider-change updates only from the new provider draft state', () => { - const sourceMonitor: IndicatorMonitorRecord = { + const sourceMonitor: MonitorRecord = { ...monitor, providerConfig: { ...monitor.providerConfig, @@ -434,9 +450,9 @@ describe('config monitor domain', () => { } const nextReferenceData: MonitorReferenceData = { ...referenceData, - streamingProviders: [...referenceData.streamingProviders, { id: 'tradier', name: 'Tradier' }], - providerById: { - ...referenceData.providerById, + marketProviders: [...referenceData.marketProviders, { id: 'tradier', name: 'Tradier' }], + marketProviderById: { + ...referenceData.marketProviderById, tradier: { id: 'tradier', name: 'Tradier' }, }, providerIntervalsByProviderId: { @@ -457,7 +473,6 @@ describe('config monitor domain', () => { workspaceId: 'workspace-1', draft, originalMonitor: sourceMonitor, - referenceData: nextReferenceData, }) expect(payload).toMatchObject({ @@ -470,7 +485,7 @@ describe('config monitor domain', () => { }) it('treats touched secret fields as replace-all auth updates', () => { - const sourceMonitor: IndicatorMonitorRecord = { + const sourceMonitor: MonitorRecord = { ...monitor, providerConfig: { ...monitor.providerConfig, @@ -516,9 +531,8 @@ describe('config monitor domain', () => { workspaceId: 'workspace-1', draft: clearedDraft, originalMonitor: sourceMonitor, - referenceData: nextReferenceData, - }).auth - ).toEqual({ secrets: {} }) + }) + ).toMatchObject({ auth: { secrets: {} } }) expect( validateMonitorDraft({ draft: partialActiveDraft, referenceData: nextReferenceData }).errors ).toMatchObject({ 'secret:apiSecret': 'API Secret is required.' }) diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/config/config-draft.ts b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/config/config-draft.ts index bfe20dd2c..04bdfaab1 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/config/config-draft.ts +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/config/config-draft.ts @@ -1,10 +1,13 @@ import type { ListingIdentity } from '@/lib/listing/identity' +import { INDICATOR_MONITOR_PROVIDER, PORTFOLIO_MONITOR_PROVIDER } from '@/lib/monitors/sources' import type { - IndicatorMonitorCreateInput, - IndicatorMonitorRecord, - IndicatorMonitorUpdateInput, + MonitorCreateInput, MonitorDraft, + MonitorRecord, MonitorReferenceData, + MonitorUpdateInput, + PortfolioMonitorCreateInput, + PortfolioMonitorUpdateInput, } from '../shared/types' import { buildDefaultDraft, buildDraftFromMonitor, isAuthParamDefinition } from '../shared/utils' @@ -46,6 +49,14 @@ const mapProviderParamsToComparableValues = ( const getProviderDefinitions = (referenceData: MonitorReferenceData, providerId: string) => referenceData.providerParamDefinitionsByProviderId[providerId] ?? [] +const getDefaultProviderIdForSource = ( + source: MonitorDraft['source'], + referenceData: MonitorReferenceData +) => + source === PORTFOLIO_MONITOR_PROVIDER + ? referenceData.defaultPortfolioProviderId + : referenceData.defaultMarketProviderId + export const getProviderIntervalFallback = ({ defaultDraftInterval, providerId, @@ -75,6 +86,20 @@ export const mergeMonitorDraftPatch = ({ patch: Partial referenceData: MonitorReferenceData }): MonitorDraft => { + const nextSource = patch.source ?? draft.source + if (nextSource !== draft.source) { + return { + ...buildDefaultDraft({ + source: nextSource, + providerId: getDefaultProviderIdForSource(nextSource, referenceData), + interval: referenceData.defaultDraftInterval, + }), + isActive: draft.isActive, + ...patch, + source: nextSource, + } + } + const nextProviderId = patch.providerId ?? draft.providerId const providerChanged = nextProviderId !== draft.providerId const nextIndicatorId = patch.indicatorId ?? draft.indicatorId @@ -94,6 +119,7 @@ export const mergeMonitorDraftPatch = ({ return { ...draft, ...patch, + source: nextSource, providerId: nextProviderId, interval: nextInterval, listing: providerChanged @@ -112,6 +138,11 @@ export const mergeMonitorDraftPatch = ({ existingEncryptedSecretFieldIds: providerChanged ? (patch.existingEncryptedSecretFieldIds ?? []) : (patch.existingEncryptedSecretFieldIds ?? draft.existingEncryptedSecretFieldIds), + serviceId: providerChanged ? (patch.serviceId ?? '') : (patch.serviceId ?? draft.serviceId), + credentialId: providerChanged + ? (patch.credentialId ?? '') + : (patch.credentialId ?? draft.credentialId), + accountId: providerChanged ? (patch.accountId ?? '') : (patch.accountId ?? draft.accountId), indicatorInputs: Object.hasOwn(patch, 'indicatorInputs') ? (patch.indicatorInputs ?? {}) : indicatorChanged @@ -120,12 +151,19 @@ export const mergeMonitorDraftPatch = ({ } } -export const buildBlankMonitorDraft = (referenceData: MonitorReferenceData) => +export const buildBlankMonitorDraft = ( + referenceData: MonitorReferenceData, + source: MonitorDraft['source'] = INDICATOR_MONITOR_PROVIDER +) => buildDefaultDraft({ - providerId: referenceData.defaultDraftProviderId, + source, + providerId: getDefaultProviderIdForSource(source, referenceData), interval: referenceData.defaultDraftInterval, }) +const hasPortfolioConditionRules = (draft: MonitorDraft) => + Array.isArray(draft.condition?.root?.rules) && draft.condition.root.rules.length > 0 + export const validateMonitorDraft = ({ draft, referenceData, @@ -140,23 +178,45 @@ export const validateMonitorDraft = ({ if (!draft.workflowId) errors.workflowId = 'Workflow is required.' if (!draft.blockId) errors.blockId = 'Block target is required.' if (!draft.providerId) errors.providerId = 'Provider is required.' - if (!draft.interval) errors.interval = 'Interval is required.' - if (!draft.indicatorId) errors.indicatorId = 'Indicator is required.' - if (!draft.listing) errors.listing = 'Listing is required.' const workflowTargetKey = `${draft.workflowId}:${draft.blockId}` - if (draft.workflowId && draft.blockId && !referenceData.workflowTargetByKey[workflowTargetKey]) { - errors.workflowId = 'Selected workflow target is not deployed with an indicator trigger.' + const workflowTarget = referenceData.workflowTargetByKey[workflowTargetKey] + if ( + draft.workflowId && + draft.blockId && + (!workflowTarget || workflowTarget.source !== draft.source) + ) { + errors.workflowId = + draft.source === PORTFOLIO_MONITOR_PROVIDER + ? 'Selected workflow target is not deployed with a portfolio state trigger.' + : 'Selected workflow target is not deployed with an indicator trigger.' + } + + if (draft.source === PORTFOLIO_MONITOR_PROVIDER) { + if (draft.providerId && !referenceData.tradingProviderById[draft.providerId]) { + errors.providerId = 'Selected trading provider is unavailable.' + } + if (!draft.serviceId) errors.serviceId = 'Trading connection is required.' + if (!draft.credentialId || !draft.accountId) errors.accountId = 'Trading account is required.' + if (!hasPortfolioConditionRules(draft)) { + errors.condition = 'At least one fire condition is required.' + } + + return { + valid: Object.keys(errors).length === 0, + errors, + } } + if (!draft.interval) errors.interval = 'Interval is required.' + if (!draft.indicatorId) errors.indicatorId = 'Indicator is required.' + if (!draft.listing) errors.listing = 'Listing is required.' if (draft.indicatorId && !referenceData.indicatorById[draft.indicatorId]) { errors.indicatorId = 'Selected indicator is unavailable.' } - - if (draft.providerId && !referenceData.providerById[draft.providerId]) { + if (draft.providerId && !referenceData.marketProviderById[draft.providerId]) { errors.providerId = 'Selected provider is unavailable.' } - const availableIntervals = referenceData.providerIntervalsByProviderId[draft.providerId] ?? [] if ( draft.interval && @@ -200,11 +260,29 @@ export const buildMonitorCreatePayloadFromDraft = ({ }: { workspaceId: string draft: MonitorDraft - referenceData: MonitorReferenceData -}): IndicatorMonitorCreateInput => { +}): MonitorCreateInput => { + if (draft.source === PORTFOLIO_MONITOR_PROVIDER) { + return { + source: PORTFOLIO_MONITOR_PROVIDER, + workspaceId, + workflowId: draft.workflowId, + blockId: draft.blockId, + providerId: draft.providerId, + serviceId: draft.serviceId, + credentialId: draft.credentialId, + accountId: draft.accountId, + condition: draft.condition, + fireMode: draft.fireMode, + cooldownSeconds: draft.cooldownSeconds, + pollIntervalSeconds: draft.pollIntervalSeconds, + isActive: draft.isActive, + } satisfies PortfolioMonitorCreateInput + } + const providerParams = trimRecordValues(draft.providerParamValues) return { + source: INDICATOR_MONITOR_PROVIDER, workspaceId, workflowId: draft.workflowId, blockId: draft.blockId, @@ -230,10 +308,27 @@ export const buildMonitorUpdatePayloadFromDraft = ({ }: { workspaceId: string draft: MonitorDraft - originalMonitor: IndicatorMonitorRecord - referenceData: MonitorReferenceData -}): IndicatorMonitorUpdateInput => { + originalMonitor: MonitorRecord +}): MonitorUpdateInput => { const originalConfig = originalMonitor.providerConfig.monitor + if (originalMonitor.source === PORTFOLIO_MONITOR_PROVIDER) { + return { + source: PORTFOLIO_MONITOR_PROVIDER, + workspaceId, + workflowId: draft.workflowId, + blockId: draft.blockId, + providerId: draft.providerId, + serviceId: draft.serviceId, + credentialId: draft.credentialId, + accountId: draft.accountId, + condition: draft.condition, + fireMode: draft.fireMode, + cooldownSeconds: draft.cooldownSeconds, + pollIntervalSeconds: draft.pollIntervalSeconds, + isActive: draft.isActive, + } satisfies PortfolioMonitorUpdateInput + } + const providerChanged = draft.providerId !== originalConfig.providerId const indicatorChanged = draft.indicatorId !== originalConfig.indicatorId const nextProviderParams = trimRecordValues(draft.providerParamValues) @@ -246,6 +341,7 @@ export const buildMonitorUpdatePayloadFromDraft = ({ ) return { + source: INDICATOR_MONITOR_PROVIDER, workspaceId, workflowId: draft.workflowId, blockId: draft.blockId, @@ -266,9 +362,9 @@ export const buildMonitorUpdatePayloadFromDraft = ({ } export const buildOptimisticMonitorRecordFromDraft = ( - monitor: IndicatorMonitorRecord, + monitor: MonitorRecord, draft: MonitorDraft -): IndicatorMonitorRecord => ({ +): MonitorRecord => ({ ...monitor, workflowId: draft.workflowId, blockId: draft.blockId, @@ -279,17 +375,29 @@ export const buildOptimisticMonitorRecordFromDraft = ( monitor: { ...monitor.providerConfig.monitor, providerId: draft.providerId, - interval: draft.interval, - indicatorId: draft.indicatorId, - listing: draft.listing as ListingIdentity, - providerParams: trimRecordValues(draft.providerParamValues), - indicatorInputs: draft.indicatorInputs, + ...(draft.source === PORTFOLIO_MONITOR_PROVIDER + ? { + serviceId: draft.serviceId, + credentialId: draft.credentialId, + accountId: draft.accountId, + condition: draft.condition, + fireMode: draft.fireMode, + cooldownSeconds: draft.cooldownSeconds, + pollIntervalSeconds: draft.pollIntervalSeconds, + } + : { + interval: draft.interval, + indicatorId: draft.indicatorId, + listing: draft.listing as ListingIdentity, + providerParams: trimRecordValues(draft.providerParamValues), + indicatorInputs: draft.indicatorInputs, + }), }, }, }) export const buildDraftFromMonitorWithPatch = ( - monitor: IndicatorMonitorRecord, + monitor: MonitorRecord, patch: Partial, referenceData: MonitorReferenceData ) => diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/config/config-drop.ts b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/config/config-drop.ts index 83f96ac3f..e3b0e9467 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/config/config-drop.ts +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/config/config-drop.ts @@ -1,9 +1,5 @@ import { toListingValueObject } from '@/lib/listing/identity' -import type { - IndicatorMonitorUpdateInput, - MonitorDraft, - MonitorReferenceData, -} from '../shared/types' +import type { MonitorDraft, MonitorReferenceData, MonitorUpdateInput } from '../shared/types' import type { ConfigMonitorViewConfig } from '../view/view-config' import type { ConfigBoardContext } from './config-board-state' import type { ConfigMonitorCard } from './config-card-model' @@ -11,7 +7,7 @@ import { getProviderIntervalFallback } from './config-draft' type ConfigDropResolution = { draftPatch: Partial - updatePatch: Partial + updatePatch: Partial errors: Record } @@ -50,7 +46,7 @@ const applyDimension = ({ } if (field === 'provider') { - if (!referenceData.providerById[value]) { + if (!referenceData.marketProviderById[value]) { errors.provider = 'Provider is unavailable.' return } diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/config/config-filter-values.ts b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/config/config-filter-values.ts index 7696eedde..533880193 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/config/config-filter-values.ts +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/config/config-filter-values.ts @@ -16,6 +16,8 @@ const sortStrings = (values: string[]) => ) const normalizeListingFilterValue = (rawValue: string) => { + if (rawValue.startsWith('portfolio:')) return rawValue + try { const parsed = JSON.parse(rawValue) const normalized = toListingValueObject(parsed) diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/config/config-search.test.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/config/config-search.test.tsx index db98f883d..9216a2676 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/config/config-search.test.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/config/config-search.test.tsx @@ -20,21 +20,26 @@ const referenceData: MonitorReferenceData = { workflowTargets: [], workflowTargetByKey: {}, workflowOptions: [], + indicatorWorkflowTargets: [], + portfolioWorkflowTargets: [], indicatorOptions: [{ id: 'rsi', name: 'RSI', source: 'default', color: '#3972F6' }], indicatorById: { rsi: { id: 'rsi', name: 'RSI', source: 'default', color: '#3972F6' }, }, - streamingProviders: [ + marketProviders: [ { id: 'alpaca', name: 'Alpaca' }, { id: 'finnhub', name: 'Finnhub' }, ], - providerById: { + marketProviderById: { alpaca: { id: 'alpaca', name: 'Alpaca' }, finnhub: { id: 'finnhub', name: 'Finnhub' }, }, providerIntervalsByProviderId: { alpaca: ['1m', '5m'], finnhub: ['1d'] }, providerParamDefinitionsByProviderId: {}, - defaultDraftProviderId: 'alpaca', + tradingProviders: [{ id: 'alpaca', name: 'Alpaca' }], + tradingProviderById: { alpaca: { id: 'alpaca', name: 'Alpaca' } }, + defaultMarketProviderId: '', + defaultPortfolioProviderId: '', defaultDraftInterval: '1m', createDisabledReason: null, isLoading: false, diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/config/config-search.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/config/config-search.tsx index cbeca43fb..0ff9742b3 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/config/config-search.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/config/config-search.tsx @@ -57,7 +57,7 @@ export function buildConfigSearchSuggestionSet( filter: { field: 'indicator', operator: '=', values: [indicator.id] }, }) ) - referenceData.streamingProviders.forEach((provider) => + referenceData.marketProviders.forEach((provider) => add({ label: provider.name, filter: { field: 'provider', operator: '=', values: [provider.id] }, diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/config/monitor-config-board.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/config/monitor-config-board.tsx index 6edb9e6ed..65a2cbc0e 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/config/monitor-config-board.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/config/monitor-config-board.tsx @@ -168,14 +168,7 @@ export function MonitorConfigBoard({ > {sections.map((section) => ( - + {section.groups.map((group) => ( { type: 'indicator_trigger', name: 'EMA Trigger', }, + 'trigger-3': { + id: 'trigger-3', + type: 'portfolio_state_trigger', + name: 'Portfolio Trigger', + }, 'block-1': { id: 'block-1', type: 'agent', @@ -133,6 +138,8 @@ describe('monitor data api', () => { await expect(loadWorkflowTargetOptions('workspace 1')).resolves.toEqual([ { + source: 'indicator', + triggerId: 'indicator_trigger', workflowId: 'workflow-1', blockId: 'trigger-1', workflowName: 'Momentum', @@ -142,6 +149,19 @@ describe('monitor data api', () => { label: 'Momentum - EMA Trigger', }, { + source: 'portfolio', + triggerId: 'portfolio_state_trigger', + workflowId: 'workflow-1', + blockId: 'trigger-3', + workflowName: 'Momentum', + workflowColor: '#111111', + isDeployed: true, + blockName: 'Portfolio Trigger', + label: 'Momentum - Portfolio Trigger', + }, + { + source: 'indicator', + triggerId: 'indicator_trigger', workflowId: 'workflow-1', blockId: 'trigger-2', workflowName: 'Momentum', diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/data/api.ts b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/data/api.ts index 5db5301ed..abc85bfb1 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/data/api.ts +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/data/api.ts @@ -1,9 +1,14 @@ +import { + getMonitorProviderForTriggerId, + getMonitorSourceByTriggerId, + isMonitorTriggerId, +} from '@/lib/monitors/sources' import type { - IndicatorMonitorCreateInput, - IndicatorMonitorRecord, - IndicatorMonitorStateUpdateInput, - IndicatorMonitorUpdateInput, IndicatorOption, + MonitorCreateInput, + MonitorRecord, + MonitorStateUpdateInput, + MonitorUpdateInput, WorkflowPickerOption, WorkflowTargetOption, } from '../shared/types' @@ -110,10 +115,10 @@ const parseMonitorViewsListResponse = async (response: Response): Promise => { +const parseMonitorResponse = async (response: Response): Promise => { const payload = await response.json().catch(() => null) const data = payload?.data - return data && typeof data === 'object' ? (data as IndicatorMonitorRecord) : null + return data && typeof data === 'object' ? (data as MonitorRecord) : null } export async function loadWorkflowTargetOptions( @@ -139,11 +144,15 @@ export async function loadWorkflowTargetOptions( return Object.entries(blocks) .map(([blockId, blockData]) => { const data = blockData as { id?: string; type?: string; name?: string } | undefined - if (data?.type !== 'indicator_trigger') return null + if (!isMonitorTriggerId(data?.type)) return null const resolvedBlockId = toTrimmed(data?.id) || blockId - const blockName = toTrimmed(data?.name) || 'Indicator Trigger' + const blockName = + toTrimmed(data?.name) || getMonitorSourceByTriggerId(data.type).triggerLabel + const source = getMonitorProviderForTriggerId(data.type) return { + source, + triggerId: data.type, workflowId: id, blockId: resolvedBlockId, workflowName: toTrimmed(workflowRow?.name) || 'Workflow', @@ -240,10 +249,8 @@ export async function loadIndicatorOptions(workspaceId: string): Promise Boolean(entry)) } -export async function loadMonitors(workspaceId: string): Promise { - const response = await fetch( - `/api/indicator-monitors?workspaceId=${encodeURIComponent(workspaceId)}` - ) +export async function loadMonitors(workspaceId: string): Promise { + const response = await fetch(`/api/monitors?workspaceId=${encodeURIComponent(workspaceId)}`) if (!response.ok) { throw new Error(await parseErrorMessage(response)) @@ -253,10 +260,8 @@ export async function loadMonitors(workspaceId: string): Promise { - const response = await fetch('/api/indicator-monitors', { +export async function createMonitorRecord(body: MonitorCreateInput): Promise { + const response = await fetch('/api/monitors', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), @@ -269,11 +274,11 @@ export async function createIndicatorMonitor( return parseMonitorResponse(response) } -export async function updateIndicatorMonitor( +export async function updateMonitorRecord( monitorId: string, - body: IndicatorMonitorUpdateInput | IndicatorMonitorStateUpdateInput -): Promise { - const response = await fetch(`/api/indicator-monitors/${encodeURIComponent(monitorId)}`, { + body: MonitorUpdateInput | MonitorStateUpdateInput +): Promise { + const response = await fetch(`/api/monitors/${encodeURIComponent(monitorId)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), @@ -286,8 +291,8 @@ export async function updateIndicatorMonitor( return parseMonitorResponse(response) } -export async function deleteIndicatorMonitor(monitorId: string) { - const response = await fetch(`/api/indicator-monitors/${encodeURIComponent(monitorId)}`, { +export async function deleteMonitorRecord(monitorId: string) { + const response = await fetch(`/api/monitors/${encodeURIComponent(monitorId)}`, { method: 'DELETE', }) diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/data/execution-ordering.ts b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/data/execution-ordering.ts index 64fc1959f..fa72fe6de 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/data/execution-ordering.ts +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/data/execution-ordering.ts @@ -28,7 +28,10 @@ export type MonitorExecutionItem = { workflowName: string workflowColor: string monitorId: string | null + source: string | null providerId: string | null + serviceId: string | null + accountId: string | null interval: string | null indicatorId: string | null assetType: string diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/data/use-monitor-execution-summaries.ts b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/data/use-monitor-execution-summaries.ts index d469c4a49..207f972bd 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/data/use-monitor-execution-summaries.ts +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/data/use-monitor-execution-summaries.ts @@ -1,10 +1,13 @@ 'use client' import { useCallback, useEffect, useMemo } from 'react' +import { MONITOR_TRIGGER_IDS } from '@/lib/monitors/sources' import { useLogsList } from '@/hooks/queries/logs' import type { WorkflowLog } from '@/stores/logs/filters/types' import type { MonitorExecutionOutcome } from './execution-ordering' +const MONITOR_TRIGGER_SOURCE_FILTER = MONITOR_TRIGGER_IDS.join(',') + type MonitorWorkflowLog = WorkflowLog & { startedAt?: string recordCreatedAt?: string @@ -118,7 +121,7 @@ export function useMonitorExecutionSummaries({ searchQuery: '', limit: 100, details: 'full' as const, - triggerSource: 'indicator_trigger' as const, + triggerSource: MONITOR_TRIGGER_SOURCE_FILTER, }), [] ) diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/data/use-monitor-reference-data.test.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/data/use-monitor-reference-data.test.tsx new file mode 100644 index 000000000..04209ce9b --- /dev/null +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/data/use-monitor-reference-data.test.tsx @@ -0,0 +1,110 @@ +/** + * @vitest-environment jsdom + */ + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { MonitorReferenceData } from '../shared/types' + +const { + loadIndicatorOptionsMock, + loadWorkflowOptionsMock, + loadWorkflowTargetOptionsMock, + fetchOAuthProviderAvailabilityMock, +} = vi.hoisted(() => ({ + loadIndicatorOptionsMock: vi.fn(), + loadWorkflowOptionsMock: vi.fn(), + loadWorkflowTargetOptionsMock: vi.fn(), + fetchOAuthProviderAvailabilityMock: vi.fn(), +})) + +vi.mock('@/hooks/queries/oauth-provider-availability', () => ({ + fetchOAuthProviderAvailability: fetchOAuthProviderAvailabilityMock, +})) + +vi.mock('./api', () => ({ + loadIndicatorOptions: loadIndicatorOptionsMock, + loadWorkflowOptions: loadWorkflowOptionsMock, + loadWorkflowTargetOptions: loadWorkflowTargetOptionsMock, +})) + +import { useMonitorReferenceData } from './use-monitor-reference-data' + +function Harness({ + onRender, + workspaceId = 'workspace-1', +}: { + onRender: (referenceData: MonitorReferenceData) => void + workspaceId?: string +}) { + onRender(useMonitorReferenceData(workspaceId)) + return null +} + +describe('useMonitorReferenceData', () => { + let container: HTMLDivElement + let root: Root + const reactActEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean + } + + beforeEach(() => { + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + loadIndicatorOptionsMock.mockResolvedValue([ + { id: 'rsi', name: 'RSI', source: 'default', color: '#3972F6' }, + ]) + loadWorkflowOptionsMock.mockResolvedValue([]) + loadWorkflowTargetOptionsMock.mockResolvedValue([ + { + source: 'portfolio', + triggerId: 'portfolio_state_trigger', + workflowId: 'workflow-1', + blockId: 'portfolio-trigger', + workflowName: 'Portfolio Workflow', + workflowColor: '#3972F6', + isDeployed: true, + blockName: 'Portfolio Trigger', + label: 'Portfolio Workflow - Portfolio Trigger', + }, + ]) + fetchOAuthProviderAvailabilityMock.mockResolvedValue({ + 'alpaca-paper': true, + 'tradier-live': false, + }) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = false + vi.clearAllMocks() + }) + + it('uses canonical OAuth service availability for portfolio monitor provider options', async () => { + const snapshots: MonitorReferenceData[] = [] + + await act(async () => { + root.render( snapshots.push(referenceData)} />) + }) + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + + expect(fetchOAuthProviderAvailabilityMock).toHaveBeenCalledWith([ + 'alpaca-live', + 'alpaca-paper', + 'tradier-live', + ]) + expect(snapshots.at(-1)?.tradingProviders).toEqual([{ id: 'alpaca', name: 'Alpaca' }]) + expect(snapshots.at(-1)?.tradingProviderById).toEqual({ + alpaca: { id: 'alpaca', name: 'Alpaca' }, + }) + expect(snapshots.at(-1)?.defaultPortfolioProviderId).toBe('') + expect(snapshots.at(-1)?.createDisabledReason).toBeNull() + }) +}) diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/data/use-monitor-reference-data.ts b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/data/use-monitor-reference-data.ts index f35cf89f6..6123f08c3 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/data/use-monitor-reference-data.ts +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/data/use-monitor-reference-data.ts @@ -1,16 +1,21 @@ 'use client' import { useCallback, useEffect, useMemo, useState } from 'react' +import { INDICATOR_MONITOR_PROVIDER, PORTFOLIO_MONITOR_PROVIDER } from '@/lib/monitors/sources' +import { fetchOAuthProviderAvailability } from '@/hooks/queries/oauth-provider-availability' import { - getMarketLiveCapabilities, - getMarketProviderOptionsByKind, - getMarketProviderParamDefinitions, - getMarketSeriesCapabilities, + getMarketMonitorProviderParamDefinitions, + getMarketProviderIntervals, + getMarketProviderOptions, + type MarketProviderOption, } from '@/providers/market/providers' +import { + getTradingWidgetProviderAvailabilityIds, + getTradingWidgetProviderOptions, +} from '@/widgets/utils/trading-widget-providers' import type { IndicatorOption, MonitorReferenceData, - StreamingProviderOption, WorkflowPickerOption, WorkflowTargetOption, } from '../shared/types' @@ -20,13 +25,18 @@ const EMPTY_REFERENCE_DATA: MonitorReferenceData = { workflowTargets: [], workflowTargetByKey: {}, workflowOptions: [], + indicatorWorkflowTargets: [], + portfolioWorkflowTargets: [], indicatorOptions: [], indicatorById: {}, - streamingProviders: [], - providerById: {}, + marketProviders: [], + marketProviderById: {}, providerIntervalsByProviderId: {}, providerParamDefinitionsByProviderId: {}, - defaultDraftProviderId: 'alpaca', + tradingProviders: [], + tradingProviderById: {}, + defaultMarketProviderId: '', + defaultPortfolioProviderId: '', defaultDraftInterval: '1m', createDisabledReason: 'No deployed workflow with indicator trigger is available, or no trigger-capable indicator exists.', @@ -38,56 +48,80 @@ const buildReferenceData = ({ workflowTargets, workflowOptions, indicatorOptions, + tradingProviderAvailability, isLoading, warning, }: { workflowTargets: WorkflowTargetOption[] workflowOptions: WorkflowPickerOption[] indicatorOptions: IndicatorOption[] + tradingProviderAvailability: Record isLoading: boolean warning: string | null }): MonitorReferenceData => { - const streamingProviders: StreamingProviderOption[] = getMarketProviderOptionsByKind('live').filter( - (option) => Boolean(getMarketLiveCapabilities(option.id)?.supportsStreaming) + const marketProviders: MarketProviderOption[] = getMarketProviderOptions() + const tradingProviders = getTradingWidgetProviderOptions( + 'portfolioDetail', + tradingProviderAvailability ) const workflowTargetByKey = Object.fromEntries( workflowTargets.map((target) => [`${target.workflowId}:${target.blockId}`, target]) ) + const indicatorWorkflowTargets = workflowTargets.filter( + (target) => target.source === INDICATOR_MONITOR_PROVIDER + ) + const portfolioWorkflowTargets = workflowTargets.filter( + (target) => target.source === PORTFOLIO_MONITOR_PROVIDER + ) const indicatorById = Object.fromEntries( indicatorOptions.map((indicator) => [indicator.id, indicator]) ) - const providerById = Object.fromEntries(streamingProviders.map((provider) => [provider.id, provider])) + const marketProviderById = Object.fromEntries( + marketProviders.map((provider) => [provider.id, provider]) + ) + const tradingProviderById = Object.fromEntries( + tradingProviders.map((provider) => [provider.id, provider]) + ) const providerIntervalsByProviderId = Object.fromEntries( - streamingProviders.map((provider) => [ - provider.id, - getMarketSeriesCapabilities(provider.id)?.intervals ?? [], - ]) + marketProviders.map((provider) => [provider.id, getMarketProviderIntervals(provider.id)]) ) const providerParamDefinitionsByProviderId = Object.fromEntries( - streamingProviders.map((provider) => [ + marketProviders.map((provider) => [ provider.id, - getMarketProviderParamDefinitions(provider.id, 'live'), + getMarketMonitorProviderParamDefinitions(provider.id), ]) ) - const defaultDraftProviderId = streamingProviders[0]?.id ?? 'alpaca' - const defaultDraftInterval = providerIntervalsByProviderId[defaultDraftProviderId]?.[0] ?? '1m' + const defaultMarketProviderId = '' + const defaultPortfolioProviderId = '' + const defaultDraftInterval = providerIntervalsByProviderId[defaultMarketProviderId]?.[0] ?? '1m' + const canCreateIndicatorMonitor = + indicatorWorkflowTargets.length > 0 && indicatorOptions.length > 0 + const canCreatePortfolioMonitor = + portfolioWorkflowTargets.length > 0 && tradingProviders.length > 0 const createDisabledReason = isLoading ? 'Loading monitor requirements...' - : workflowTargets.length > 0 && indicatorOptions.length > 0 + : canCreateIndicatorMonitor || canCreatePortfolioMonitor ? null - : 'No deployed workflow with indicator trigger is available, or no trigger-capable indicator exists.' + : portfolioWorkflowTargets.length > 0 && tradingProviders.length === 0 + ? 'No enabled trading provider is available for portfolio monitors.' + : 'No deployed workflow with a monitor trigger is available, or no trigger-capable indicator exists.' return { workflowTargets, workflowTargetByKey, workflowOptions, + indicatorWorkflowTargets, + portfolioWorkflowTargets, indicatorOptions, indicatorById, - streamingProviders, - providerById, + marketProviders, + marketProviderById, providerIntervalsByProviderId, providerParamDefinitionsByProviderId, - defaultDraftProviderId, + tradingProviders, + tradingProviderById, + defaultMarketProviderId, + defaultPortfolioProviderId, defaultDraftInterval, createDisabledReason, isLoading, @@ -99,18 +133,27 @@ export function useMonitorReferenceData(workspaceId: string): MonitorReferenceDa const [workflowTargets, setWorkflowTargets] = useState([]) const [workflowOptions, setWorkflowOptions] = useState([]) const [indicatorOptions, setIndicatorOptions] = useState([]) + const [tradingProviderAvailability, setTradingProviderAvailability] = useState< + Record + >({}) const [isLoading, setIsLoading] = useState(true) const [warning, setWarning] = useState(null) + const tradingProviderAvailabilityIds = useMemo( + () => getTradingWidgetProviderAvailabilityIds('portfolioDetail'), + [] + ) const loadReferenceData = useCallback(async () => { setIsLoading(true) setWarning(null) - const [indicatorResult, targetsResult, workflowsResult] = await Promise.allSettled([ - loadIndicatorOptions(workspaceId), - loadWorkflowTargetOptions(workspaceId), - loadWorkflowOptions(workspaceId), - ]) + const [indicatorResult, targetsResult, workflowsResult, tradingProviderAvailabilityResult] = + await Promise.allSettled([ + loadIndicatorOptions(workspaceId), + loadWorkflowTargetOptions(workspaceId), + loadWorkflowOptions(workspaceId), + fetchOAuthProviderAvailability(tradingProviderAvailabilityIds), + ]) let nextWarning: string | null = null @@ -135,15 +178,23 @@ export function useMonitorReferenceData(workspaceId: string): MonitorReferenceDa nextWarning = nextWarning ?? 'Workflow options are unavailable right now.' } + if (tradingProviderAvailabilityResult.status === 'fulfilled') { + setTradingProviderAvailability(tradingProviderAvailabilityResult.value) + } else { + setTradingProviderAvailability({}) + nextWarning = nextWarning ?? 'Trading provider availability is unavailable right now.' + } + setWarning(nextWarning) setIsLoading(false) - }, [workspaceId]) + }, [tradingProviderAvailabilityIds, workspaceId]) useEffect(() => { if (!workspaceId) { setWorkflowTargets([]) setWorkflowOptions([]) setIndicatorOptions([]) + setTradingProviderAvailability({}) setIsLoading(false) setWarning(null) return @@ -159,10 +210,19 @@ export function useMonitorReferenceData(workspaceId: string): MonitorReferenceDa workflowTargets, workflowOptions, indicatorOptions, + tradingProviderAvailability, isLoading, warning, }) : { ...EMPTY_REFERENCE_DATA, isLoading: false }, - [indicatorOptions, isLoading, warning, workflowOptions, workflowTargets, workspaceId] + [ + indicatorOptions, + isLoading, + tradingProviderAvailability, + warning, + workflowOptions, + workflowTargets, + workspaceId, + ] ) } diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/data/use-monitor-workspace-logs.test.ts b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/data/use-monitor-workspace-logs.test.ts index 9aa6b896b..a57bf486c 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/data/use-monitor-workspace-logs.test.ts +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/data/use-monitor-workspace-logs.test.ts @@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser' import { MONITOR_QUERY_POLICY } from '@/lib/logs/query-policy' import { useLogsList } from '@/hooks/queries/logs' -import type { IndicatorMonitorRecord } from '../shared/types' +import type { MonitorRecord } from '../shared/types' import { DEFAULT_EXECUTION_MONITOR_VIEW_CONFIG } from '../view/view-config' import { buildMonitorExecutionLogFilters, @@ -80,7 +80,7 @@ function HookHarness({ }, }: { onRender: (value: ReturnType) => void - monitors?: IndicatorMonitorRecord[] + monitors?: MonitorRecord[] viewConfig?: typeof DEFAULT_EXECUTION_MONITOR_VIEW_CONFIG }) { const value = useMonitorWorkspaceLogs({ @@ -146,7 +146,7 @@ describe('useMonitorWorkspaceLogs', () => { searchQuery: 'provider:#alpaca workflow:#wf-1', queryPolicy: expect.objectContaining({ key: 'monitor' }), queryPolicyKey: 'monitor', - triggerSource: 'indicator_trigger', + triggerSource: 'indicator_trigger,portfolio_state_trigger', }) ) expect(snapshots.at(-1)?.executionItems[0]?.monitorId).toBe('monitor-1') @@ -285,7 +285,7 @@ describe('useMonitorWorkspaceLogs', () => { ...DEFAULT_EXECUTION_MONITOR_VIEW_CONFIG, quickFilters: [{ field: 'assetType', operator: 'include', values: ['stock'] }], }, - monitors: [{ monitorId: 'monitor-1' } as IndicatorMonitorRecord], + monitors: [{ monitorId: 'monitor-1' } as MonitorRecord], onRender: (value) => { snapshots.push(value) }, diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/data/use-monitor-workspace-logs.ts b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/data/use-monitor-workspace-logs.ts index 0b88ee538..7ec1e91e1 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/data/use-monitor-workspace-logs.ts +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/data/use-monitor-workspace-logs.ts @@ -3,10 +3,15 @@ import { type ListingInputValue, toListingValueObject } from '@/lib/listing/iden import { createSearchClause, serializeQuery } from '@/lib/logs/query-parser' import { MONITOR_QUERY_POLICY } from '@/lib/logs/query-policy' import type { SearchClause } from '@/lib/logs/query-types' +import { + INDICATOR_MONITOR_TRIGGER_ID, + MONITOR_TRIGGER_IDS, + PORTFOLIO_MONITOR_TRIGGER_ID, +} from '@/lib/monitors/sources' import { useLogsList } from '@/hooks/queries/logs' import type { WorkflowLog } from '@/stores/logs/filters/types' import { buildMonitorBoardSections } from '../board/board-state' -import type { IndicatorMonitorRecord } from '../shared/types' +import type { MonitorRecord } from '../shared/types' import { buildMonitorTimelineGroups } from '../timeline/timeline-state' import type { ExecutionMonitorQuickFilter, @@ -30,6 +35,7 @@ const QUICK_FILTER_FIELD_TO_QUERY_FIELD: Record buildMonitorExecutionLogFilters(viewConfig), [viewConfig]) diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/management/monitor-editor-form.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/management/monitor-editor-form.tsx index de25fb405..84dbc9c14 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/management/monitor-editor-form.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/management/monitor-editor-form.tsx @@ -1,6 +1,10 @@ 'use client' +import { useMemo } from 'react' import { ListingSearchInput } from '@/components/listing-selector/selector/input' +import { MarketProviderSelector } from '@/components/market-selector/provider-selector' +import { TradingAccountSelector } from '@/components/trading-selector/account-selector' +import { TradingProviderSelector } from '@/components/trading-selector/provider-selector' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' @@ -12,25 +16,34 @@ import { SelectValue, } from '@/components/ui/select' import { Switch } from '@/components/ui/switch' +import { TooltipProvider } from '@/components/ui/tooltip' import type { InputMetaMap } from '@/lib/indicators/types' import { toListingValue } from '@/lib/listing/identity' +import { INDICATOR_MONITOR_PROVIDER, PORTFOLIO_MONITOR_PROVIDER } from '@/lib/monitors/sources' import { cn } from '@/lib/utils' -import type { MarketProviderParamDefinition } from '@/providers/market/providers' +import type { + MarketProviderOption, + MarketProviderParamDefinition, +} from '@/providers/market/providers' +import type { PortfolioIdentity } from '@/providers/trading/portfolio-identity' import { getProviderIntervalFallback } from '../config/config-draft' import type { IndicatorOption, MonitorDraft, - StreamingProviderOption, + TradingProviderOption, WorkflowTargetOption, } from '../shared/types' import { IndicatorInputFields } from './indicator-input-fields' +import { PortfolioConditionBuilder } from './portfolio-condition-builder' type MonitorEditorFormProps = { + workspaceId: string editingKey: string | null draft: MonitorDraft errors: Record saving: boolean - streamingProviders: StreamingProviderOption[] + marketProviders: MarketProviderOption[] + tradingProviders: TradingProviderOption[] providerIntervals: string[] providerIntervalsByProviderId: Record defaultDraftInterval: string @@ -48,12 +61,63 @@ type MonitorEditorFormProps = { onUpdateIndicatorInputs: (nextInputs: Record) => void } +function WorkflowTargetSelect({ + value, + targets, + errors, + onUpdateDraft, +}: { + value?: string + targets: WorkflowTargetOption[] + errors: Record + onUpdateDraft: (patch: Partial) => void +}) { + return ( +
+ + + {errors.workflowId || errors.blockId || errors.workflowTarget ? ( +

+ {errors.workflowTarget || errors.blockId || errors.workflowId} +

+ ) : null} +
+ ) +} + export function MonitorEditorForm({ + workspaceId, editingKey, draft, errors, saving, - streamingProviders, + marketProviders, + tradingProviders, providerIntervals, providerIntervalsByProviderId, defaultDraftInterval, @@ -72,260 +136,389 @@ export function MonitorEditorForm({ }: MonitorEditorFormProps) { const workflowTargetValue = draft.workflowId && draft.blockId ? `${draft.workflowId}:${draft.blockId}` : undefined + const availableWorkflowTargets = workflowTargets.filter( + (target) => target.source === draft.source + ) const intervalOptions = providerIntervals.length > 0 ? providerIntervals : draft.interval ? [draft.interval] : [] + const selectedPortfolioIdentity = useMemo(() => { + if ( + draft.source !== PORTFOLIO_MONITOR_PROVIDER || + !draft.providerId || + !draft.serviceId || + !draft.credentialId || + !draft.accountId + ) { + return null + } + + return { + providerId: draft.providerId, + serviceId: draft.serviceId, + credentialId: draft.credentialId, + accountId: draft.accountId, + } + }, [draft.accountId, draft.credentialId, draft.providerId, draft.serviceId, draft.source]) return ( -
-
-
-
-
Monitor status
-
- New monitors start paused unless enabled here. + +
+
+
+
+
Monitor status
+
+ New monitors start paused unless enabled here. +
+ onUpdateDraft({ isActive })} + />
- onUpdateDraft({ isActive })} - /> -
-
0 && 'sm:grid-cols-2')}>
- + - {errors.providerId ? ( -

{errors.providerId}

- ) : null}
- {nonSecretDefinitions.length > 0 ? ( -
- - {nonSecretDefinitions.map((definition) => { - const key = `param:${definition.id}` - const value = draft.providerParamValues[definition.id] ?? '' - return ( -
- {definition.options && definition.options.length > 0 ? ( - - ) : ( - - onUpdateProviderParamValue(definition.id, event.target.value) - } - /> - )} - {errors[key] ? ( -

{errors[key]}

- ) : null} + {draft.source === PORTFOLIO_MONITOR_PROVIDER ? ( + <> +
+
+ + + onUpdateDraft({ + providerId, + serviceId: '', + credentialId: '', + accountId: '', + }) + } + /> + {errors.providerId ? ( +

{errors.providerId}

+ ) : null} +
+ +
+ + { + const account = selection.portfolioIdentity + onUpdateDraft({ + serviceId: account?.serviceId ?? selection.serviceId ?? '', + credentialId: account?.credentialId ?? '', + accountId: account?.accountId ?? '', + }) + }} + /> + {errors.accountId || errors.credentialId || errors.serviceId ? ( +

+ {errors.accountId || errors.credentialId || errors.serviceId} +

+ ) : null} +
+
+ + + + onUpdateDraft({ condition })} + /> + +
+
+ + +
+
+ + + onUpdateDraft({ cooldownSeconds: Number(event.target.value) }) + } + /> +
+
+ + + onUpdateDraft({ pollIntervalSeconds: Number(event.target.value) }) + } + /> +
+
+ + ) : ( + <> +
0 && 'sm:grid-cols-2')} + > +
+ + { + const nextIntervals = providerIntervalsByProviderId[nextProviderId] ?? [] + onUpdateDraft({ + providerId: nextProviderId, + interval: nextIntervals.includes(draft.interval as any) + ? draft.interval + : getProviderIntervalFallback({ + defaultDraftInterval, + providerId: nextProviderId, + providerIntervalsByProviderId, + }), + }) + }} + /> + {errors.providerId ? ( +

{errors.providerId}

+ ) : null} +
+ + {nonSecretDefinitions.length > 0 ? ( +
+ + {nonSecretDefinitions.map((definition) => { + const key = `param:${definition.id}` + const value = draft.providerParamValues[definition.id] ?? '' + return ( +
+ {definition.options && definition.options.length > 0 ? ( + + ) : ( + + onUpdateProviderParamValue(definition.id, event.target.value) + } + /> + )} + {errors[key] ? ( +

{errors[key]}

+ ) : null} +
+ ) + })}
- ) - })} -
- ) : null} -
+ ) : null} +
- {secretDefinitions.length > 0 ? ( -
- -
1 && 'sm:grid-cols-2')}> - {secretDefinitions.map((definition) => { - const key = `secret:${definition.id}` - const normalizedId = definition.id.replace(/\s+/g, '').toLowerCase() - const isPassword = definition.password || normalizedId.includes('secret') - return ( -
- onUpdateSecretValue(definition.id, event.target.value)} - placeholder={definition.title || definition.id} - type={ - definition.type === 'number' ? 'number' : isPassword ? 'password' : 'text' + {secretDefinitions.length > 0 ? ( +
+ +
1 && 'sm:grid-cols-2')} + > + {secretDefinitions.map((definition) => { + const key = `secret:${definition.id}` + const normalizedId = definition.id.replace(/\s+/g, '').toLowerCase() + const isPassword = definition.password || normalizedId.includes('secret') + return ( +
+ + onUpdateSecretValue(definition.id, event.target.value) + } + placeholder={definition.title || definition.id} + type={ + definition.type === 'number' + ? 'number' + : isPassword + ? 'password' + : 'text' + } + autoComplete='off' + disabled={saving} + /> + {errors[key] ? ( +

{errors[key]}

+ ) : null} +
+ ) + })} +
+
+ ) : null} + +
+
+ + {listingInstanceId ? ( + + onUpdateDraft({ listing: toListingValue(listing) }) } - autoComplete='off' - disabled={saving} /> - {errors[key] ? ( -

{errors[key]}

- ) : null} -
- ) - })} -
-
- ) : null} + ) : null} + {errors.listing ? ( +

{errors.listing}

+ ) : null} +
-
-
- - {listingInstanceId ? ( - onUpdateDraft({ listing: toListingValue(listing) })} - /> - ) : null} - {errors.listing ? ( -

{errors.listing}

- ) : null} -
+
+ + + {errors.interval ? ( +

{errors.interval}

+ ) : null} +
+
-
- - - {errors.interval ? ( -

{errors.interval}

- ) : null} -
-
+
+ -
-
- - onUpdateDraft({ indicatorId })} > - {target.label} - - ))} - - - {errors.workflowId || errors.blockId || errors.workflowTarget ? ( -

- {errors.workflowTarget || errors.blockId || errors.workflowId} -

- ) : null} -
+ + + + + {indicatorPickerOptions.map((option) => ( + + {option.name} + + ))} + + + {errors.indicatorId || errors.indicator ? ( +

+ {errors.indicator || errors.indicatorId} +

+ ) : null} +
+
-
- - - {errors.indicatorId || errors.indicator ? ( -

- {errors.indicator || errors.indicatorId} -

- ) : null} -
+ + + )}
- -
- -
- - +
+ + +
-
+ ) } diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/management/monitor-editor-panel.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/management/monitor-editor-panel.tsx index 16e1530b7..e07b445a5 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/management/monitor-editor-panel.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/management/monitor-editor-panel.tsx @@ -12,12 +12,14 @@ import { } from '@/components/ui/card' import { Sheet, SheetContent } from '@/components/ui/sheet' import { useIsMobile } from '@/hooks/use-mobile' +import { PORTFOLIO_MONITOR_PROVIDER } from '@/lib/monitors/sources' import type { MonitorReferenceData } from '../shared/types' import { IndicatorInputSummary } from './indicator-input-fields' import { MonitorEditorForm } from './monitor-editor-form' import type { MonitorEditorState } from './use-monitor-editor-state' type MonitorEditorPanelProps = { + workspaceId: string editorState: MonitorEditorState referenceData: MonitorReferenceData createDisabled?: boolean @@ -34,15 +36,20 @@ function MonitorDetails({ if (!monitor) return null const monitorConfig = monitor.providerConfig.monitor - const indicator = referenceData.indicatorById[monitorConfig.indicatorId] + const indicator = monitorConfig.indicatorId + ? referenceData.indicatorById[monitorConfig.indicatorId] + : undefined const workflowTarget = referenceData.workflowTargetByKey[`${monitor.workflowId}:${monitor.blockId}`] + const isPortfolio = monitor.source === PORTFOLIO_MONITOR_PROVIDER return ( - {indicator?.name ?? monitorConfig.indicatorId} + {isPortfolio + ? monitorConfig.accountId || 'Portfolio state' + : (indicator?.name ?? monitorConfig.indicatorId)} {workflowTarget?.label ?? `${monitor.workflowId}:${monitor.blockId}`} @@ -57,13 +64,18 @@ function MonitorDetails({
Provider
- {referenceData.providerById[monitorConfig.providerId]?.name ?? - monitorConfig.providerId} + {isPortfolio + ? (referenceData.tradingProviderById[monitorConfig.providerId]?.name ?? + monitorConfig.providerId) + : (referenceData.marketProviderById[monitorConfig.providerId]?.name ?? + monitorConfig.providerId)}
-
Interval
-
{monitorConfig.interval}
+
+ {isPortfolio ? 'Fire mode' : 'Interval'} +
+
{isPortfolio ? monitorConfig.fireMode : monitorConfig.interval}
Status
@@ -73,11 +85,25 @@ function MonitorDetails({
Monitor ID
{monitor.monitorId}
+ {isPortfolio ? ( + <> +
+
Account
+
{monitorConfig.accountId}
+
+
+
Cooldown
+
{monitorConfig.cooldownSeconds ?? 0}s
+
+ + ) : null}
- + {!isPortfolio ? ( + + ) : null} @@ -117,6 +143,7 @@ function EditorContent({ createDisabled = false, editorState, referenceData, + workspaceId, }: MonitorEditorPanelProps) { if (editorState.isEditorOpen && editorState.editingDraft) { return ( @@ -133,11 +160,13 @@ function EditorContent({ ) : null} void +} + +const METRIC_LABELS: Record = { + 'summary.totalPortfolioValue': 'Total portfolio value', + 'summary.totalCashValue': 'Cash value', + 'summary.totalHoldingsValue': 'Holdings value', + 'summary.totalUnrealizedPnl': 'Unrealized P/L', + 'summary.buyingPower': 'Buying power', + 'summary.equity': 'Equity', + 'positions.count': 'Open position count', + 'positions.totalMarketValue': 'Positions market value', + 'positions.totalUnrealizedPnl': 'Positions unrealized P/L', + 'position.quantity': 'Position quantity', + 'position.marketValue': 'Position market value', + 'position.unrealizedPnl': 'Position unrealized P/L', + 'position.unrealizedPnlPercent': 'Position unrealized P/L %', + 'position.exists': 'Position exists', +} + +const OPERATOR_LABELS: Record = { + gt: '>', + gte: '>=', + lt: '<', + lte: '<=', + eq: '=', + neq: '!=', + crosses_above: 'Crosses above', + crosses_below: 'Crosses below', + changes_since_previous_by_abs: 'Changes since previous update', + changes_since_previous_by_percent: 'Changes % since previous update', + exists: 'Exists', + not_exists: 'Does not exist', +} + +const createId = () => + typeof crypto !== 'undefined' && 'randomUUID' in crypto + ? crypto.randomUUID() + : Math.random().toString(36).slice(2) + +const createRule = (): PortfolioConditionRule => ({ + id: createId(), + metric: 'summary.totalPortfolioValue', + operator: 'gt', + value: 0, +}) + +const createGroup = (): PortfolioConditionGroup => ({ + id: createId(), + combinator: 'and', + rules: [createRule()], +}) + +const isGroup = (node: PortfolioConditionNode): node is PortfolioConditionGroup => + Array.isArray((node as PortfolioConditionGroup).rules) + +const normalizeRuleForMetric = ( + rule: PortfolioConditionRule, + metric: PortfolioConditionMetric +): PortfolioConditionRule => { + const operators = getPortfolioConditionOperatorsForMetric(metric) + const operator = operators.includes(rule.operator) ? rule.operator : operators[0]! + + return { + ...rule, + metric, + operator, + ...(portfolioConditionRequiresListing(metric) + ? { listing: rule.listing ?? null } + : { listing: null }), + ...(isPortfolioConditionValuelessOperator(operator) + ? { value: null } + : { value: rule.value ?? 0 }), + } +} + +const updateNodeAtPath = ( + group: PortfolioConditionGroup, + path: number[], + updater: (node: PortfolioConditionNode) => PortfolioConditionNode +): PortfolioConditionGroup => { + if (path.length === 0) return updater(group) as PortfolioConditionGroup + const [index, ...rest] = path + + return { + ...group, + rules: group.rules.map((node, nodeIndex) => { + if (nodeIndex !== index) return node + if (rest.length === 0) return updater(node) + return isGroup(node) ? updateNodeAtPath(node, rest, updater) : node + }), + } +} + +const removeNodeAtPath = ( + group: PortfolioConditionGroup, + path: number[] +): PortfolioConditionGroup => { + const [index, ...rest] = path + if (index === undefined) return group + + if (rest.length === 0) { + const nextRules = group.rules.filter((_, nodeIndex) => nodeIndex !== index) + return { ...group, rules: nextRules.length > 0 ? nextRules : [createRule()] } + } + + return { + ...group, + rules: group.rules.map((node, nodeIndex) => + nodeIndex === index && isGroup(node) ? removeNodeAtPath(node, rest) : node + ), + } +} + +export function PortfolioConditionBuilder({ + condition, + disabled = false, + error, + tradingProviderId, + onChange, +}: PortfolioConditionBuilderProps) { + const root = condition.root?.rules?.length ? condition.root : createGroup() + const updateRoot = (nextRoot: PortfolioConditionGroup) => onChange({ root: nextRoot }) + + return ( +
+ + updateRoot(updateNodeAtPath(root, path, updater))} + onRemove={(path) => updateRoot(removeNodeAtPath(root, path))} + /> + {error ?

{error}

: null} +
+ ) +} + +function ConditionGroupEditor({ + group, + path, + disabled, + tradingProviderId, + onUpdate, + onRemove, +}: { + group: PortfolioConditionGroup + path: number[] + disabled: boolean + tradingProviderId?: string + onUpdate: ( + path: number[], + updater: (node: PortfolioConditionNode) => PortfolioConditionNode + ) => void + onRemove: (path: number[]) => void +}) { + const addRule = () => + onUpdate(path, (node) => + isGroup(node) ? { ...node, rules: node.rules.concat(createRule()) } : node + ) + const addGroup = () => + onUpdate(path, (node) => + isGroup(node) ? { ...node, rules: node.rules.concat(createGroup()) } : node + ) + + return ( +
0 && 'bg-muted/20')}> +
+ + +
+ + + {path.length > 0 ? ( + + ) : null} +
+
+ +
+ {group.rules.map((node, index) => + isGroup(node) ? ( + + ) : ( + + ) + )} +
+
+ ) +} + +function ConditionRuleEditor({ + rule, + path, + disabled, + tradingProviderId, + onUpdate, + onRemove, +}: { + rule: PortfolioConditionRule + path: number[] + disabled: boolean + tradingProviderId?: string + onUpdate: ( + path: number[], + updater: (node: PortfolioConditionNode) => PortfolioConditionNode + ) => void + onRemove: (path: number[]) => void +}) { + const operators = getPortfolioConditionOperatorsForMetric(rule.metric) + const showListing = portfolioConditionRequiresListing(rule.metric) + const showValue = !isPortfolioConditionValuelessOperator(rule.operator) + const ruleListingInstanceId = showListing + ? `monitor-portfolio-condition-${rule.id ?? path.join('-')}` + : null + const updateListingSelectorInstance = useListingSelectorStore((state) => state.updateInstance) + + useEffect(() => { + if (!ruleListingInstanceId) return + updateListingSelectorInstance(ruleListingInstanceId, { + selectedListingValue: rule.listing ?? null, + selectedListing: rule.listing as any, + query: '', + results: [], + error: undefined, + }) + }, [rule.listing, ruleListingInstanceId, updateListingSelectorInstance]) + + return ( +
+ + + + + {showListing && ruleListingInstanceId ? ( + + onUpdate(path, (node) => + isGroup(node) ? node : { ...node, listing: toListingValue(listing) } + ) + } + onListingValueChange={() => + onUpdate(path, (node) => (isGroup(node) ? node : { ...node, listing: null })) + } + /> + ) : null} + {showValue ? ( + + onUpdate(path, (node) => + isGroup(node) ? node : { ...node, value: Number(event.target.value) } + ) + } + /> + ) : null} + + +
+ ) +} diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/management/use-monitor-editor-state.test.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/management/use-monitor-editor-state.test.tsx index 976c401c8..f297d4ec5 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/management/use-monitor-editor-state.test.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/management/use-monitor-editor-state.test.tsx @@ -5,7 +5,7 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { IndicatorMonitorRecord, MonitorReferenceData } from '../shared/types' +import type { MonitorRecord, MonitorReferenceData } from '../shared/types' import { DEFAULT_CONFIG_MONITOR_VIEW_CONFIG } from '../view/view-config' import { useMonitorEditorState } from './use-monitor-editor-state' @@ -13,13 +13,18 @@ const referenceData: MonitorReferenceData = { workflowTargets: [], workflowTargetByKey: {}, workflowOptions: [], + indicatorWorkflowTargets: [], + portfolioWorkflowTargets: [], indicatorOptions: [], indicatorById: {}, - streamingProviders: [{ id: 'alpaca', name: 'Alpaca' }], - providerById: { alpaca: { id: 'alpaca', name: 'Alpaca' } }, + marketProviders: [{ id: 'alpaca', name: 'Alpaca' }], + marketProviderById: { alpaca: { id: 'alpaca', name: 'Alpaca' } }, providerIntervalsByProviderId: { alpaca: ['1m'] }, providerParamDefinitionsByProviderId: {}, - defaultDraftProviderId: 'alpaca', + tradingProviders: [{ id: 'alpaca', name: 'Alpaca' }], + tradingProviderById: { alpaca: { id: 'alpaca', name: 'Alpaca' } }, + defaultMarketProviderId: '', + defaultPortfolioProviderId: '', defaultDraftInterval: '1m', createDisabledReason: null, isLoading: false, @@ -28,6 +33,7 @@ const referenceData: MonitorReferenceData = { const monitor = { monitorId: 'monitor-1', + source: 'indicator', workflowId: 'workflow-1', blockId: 'block-1', isActive: true, @@ -43,7 +49,7 @@ const monitor = { }, createdAt: '2026-04-23T00:00:00.000Z', updatedAt: '2026-04-24T00:00:00.000Z', -} satisfies IndicatorMonitorRecord +} satisfies MonitorRecord const actions = { createMonitor: vi.fn(), @@ -52,7 +58,7 @@ const actions = { deleteMonitor: vi.fn(), } -const Harness = ({ records }: { records: IndicatorMonitorRecord[] }) => { +const Harness = ({ records }: { records: MonitorRecord[] }) => { const state = useMonitorEditorState({ workspaceId: 'workspace-1', monitorRecords: records, diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/management/use-monitor-editor-state.ts b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/management/use-monitor-editor-state.ts index 1e378f62d..b13e4be6d 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/management/use-monitor-editor-state.ts +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/management/use-monitor-editor-state.ts @@ -1,6 +1,7 @@ 'use client' import { useCallback, useEffect, useMemo, useState } from 'react' +import { INDICATOR_MONITOR_PROVIDER } from '@/lib/monitors/sources' import { useListingSelectorStore } from '@/stores/market/selector/store' import type { ConfigBoardContext } from '../config/config-board-state' import { @@ -13,8 +14,8 @@ import { } from '../config/config-draft' import { resolveConfigBoardContextPatch } from '../config/config-drop' import type { - IndicatorMonitorRecord, MonitorDraft, + MonitorRecord, MonitorRecordActions, MonitorReferenceData, } from '../shared/types' @@ -31,7 +32,7 @@ export function useMonitorEditorState({ viewConfig, }: { workspaceId: string - monitorRecords: IndicatorMonitorRecord[] + monitorRecords: MonitorRecord[] referenceData: MonitorReferenceData monitorActions: MonitorRecordActions viewConfig: ConfigMonitorViewConfig @@ -106,10 +107,18 @@ export function useMonitorEditorState({ ) const editingListingInstanceId = - isEditorOpen && editingDraft ? `indicator-monitor-edit-${editingKey ?? 'new'}` : null + isEditorOpen && editingDraft?.source === INDICATOR_MONITOR_PROVIDER + ? `monitor-edit-${editingKey ?? 'new'}` + : null useEffect(() => { - if (!editingDraft?.providerId || !editingListingInstanceId) return + if ( + editingDraft?.source !== INDICATOR_MONITOR_PROVIDER || + !editingDraft.providerId || + !editingListingInstanceId + ) { + return + } updateListingSelectorInstance(editingListingInstanceId, { providerId: editingDraft.providerId, selectedListingValue: editingDraft.listing, @@ -118,26 +127,29 @@ export function useMonitorEditorState({ }, [ editingDraft?.listing, editingDraft?.providerId, + editingDraft?.source, editingListingInstanceId, updateListingSelectorInstance, ]) const openDraft = useCallback( (key: string | null, draft: MonitorDraft, errors: Record = {}) => { - const instanceId = `indicator-monitor-edit-${key ?? 'new'}` - ensureListingSelectorInstance(instanceId, { - providerId: draft.providerId, - selectedListingValue: draft.listing, - selectedListing: draft.listing as any, - query: '', - results: [], - error: undefined, - }) - updateListingSelectorInstance(instanceId, { - providerId: draft.providerId, - selectedListingValue: draft.listing, - selectedListing: draft.listing as any, - }) + if (draft.source === INDICATOR_MONITOR_PROVIDER) { + const instanceId = `monitor-edit-${key ?? 'new'}` + ensureListingSelectorInstance(instanceId, { + providerId: draft.providerId, + selectedListingValue: draft.listing, + selectedListing: draft.listing as any, + query: '', + results: [], + error: undefined, + }) + updateListingSelectorInstance(instanceId, { + providerId: draft.providerId, + selectedListingValue: draft.listing, + selectedListing: draft.listing as any, + }) + } setEditingKey(key) setEditingDraft(draft) setEditingErrors(errors) @@ -148,7 +160,7 @@ export function useMonitorEditorState({ ) const openEdit = useCallback( - (monitor: IndicatorMonitorRecord) => { + (monitor: MonitorRecord) => { selectMonitorId(monitor.monitorId) openDraft(monitor.monitorId, buildDraftFromMonitor(monitor)) }, @@ -173,7 +185,7 @@ export function useMonitorEditorState({ const openRejectedDropProposal = useCallback( ( - monitor: IndicatorMonitorRecord, + monitor: MonitorRecord, proposal: { draftPatch: Partial; errors: Record } ) => { selectMonitorId(monitor.monitorId) @@ -256,14 +268,12 @@ export function useMonitorEditorState({ workspaceId, draft: editingDraft, originalMonitor: sourceMonitor, - referenceData, }) ) : await monitorActions.createMonitor( buildMonitorCreatePayloadFromDraft({ workspaceId, draft: editingDraft, - referenceData, }) ) @@ -286,7 +296,7 @@ export function useMonitorEditorState({ ]) const toggleMonitorState = useCallback( - async (monitor: IndicatorMonitorRecord) => { + async (monitor: MonitorRecord) => { const nextIsActive = !monitor.isActive setTogglingMonitorId(monitor.monitorId) setPanelError(null) diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/shared/monitor-ui.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/shared/monitor-ui.tsx index d340ab2c3..94212c299 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/shared/monitor-ui.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/shared/monitor-ui.tsx @@ -36,15 +36,12 @@ export function MonitorControlBar({
{children}
@@ -178,7 +175,7 @@ export function MonitorBoardShell({ return ( & { - description?: ReactNode - title: ReactNode -} - -export function MonitorSectionHeader({ - children, - className, - description, - title, - ...props -}: MonitorSectionHeaderProps) { - return ( -
-
-

{title}

- {description ?

{description}

: null} -
- {children ?
{children}
: null} -
- ) -} type MonitorAggregateBadgesProps = ComponentProps<'div'> & { badgeClassName?: string diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/shared/types.ts b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/shared/types.ts index b09fabe00..2ddfce43e 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/shared/types.ts +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/shared/types.ts @@ -1,7 +1,19 @@ import type { ComponentType } from 'react' import type { InputMetaMap } from '@/lib/indicators/types' import type { ListingIdentity } from '@/lib/listing/identity' -import type { MarketProviderParamDefinition } from '@/providers/market/providers' +import type { PortfolioFireCondition } from '@/lib/monitors/portfolio-conditions' +import { + INDICATOR_MONITOR_PROVIDER, + PORTFOLIO_MONITOR_PROVIDER, + type MonitorTriggerId, + type MonitorWebhookProvider, +} from '@/lib/monitors/sources' +import type { + MarketProviderOption, + MarketProviderParamDefinition, +} from '@/providers/market/providers' + +export type MonitorSource = MonitorWebhookProvider export type IndicatorOption = { id: string @@ -13,6 +25,8 @@ export type IndicatorOption = { } export type WorkflowTargetOption = { + source: MonitorSource + triggerId: MonitorTriggerId workflowId: string blockId: string workflowName: string @@ -22,19 +36,27 @@ export type WorkflowTargetOption = { label: string } -export type IndicatorMonitorRecord = { +export type MonitorRecord = { monitorId: string + source: MonitorSource workflowId: string blockId: string isActive: boolean providerConfig: { - triggerId: 'indicator_trigger' + triggerId: MonitorTriggerId version: 1 monitor: { providerId: string - interval: string - listing: ListingIdentity - indicatorId: string + interval?: string + listing?: ListingIdentity + indicatorId?: string + serviceId?: string + credentialId?: string + accountId?: string + condition?: PortfolioFireCondition + fireMode?: 'edge' | 'while_true' + cooldownSeconds?: number + pollIntervalSeconds?: number indicatorInputs?: Record auth?: { hasEncryptedSecrets?: boolean @@ -48,12 +70,20 @@ export type IndicatorMonitorRecord = { } export type MonitorDraft = { + source: MonitorSource workflowId: string blockId: string providerId: string interval: string indicatorId: string listing: ListingIdentity | null + serviceId: string + credentialId: string + accountId: string + condition: PortfolioFireCondition + fireMode: 'edge' | 'while_true' + cooldownSeconds: number + pollIntervalSeconds: number secretValues: Record providerParamValues: Record indicatorInputs: Record @@ -62,6 +92,7 @@ export type MonitorDraft = { } export type IndicatorMonitorCreateInput = { + source: typeof INDICATOR_MONITOR_PROVIDER workspaceId: string workflowId: string blockId: string @@ -78,6 +109,7 @@ export type IndicatorMonitorCreateInput = { } export type IndicatorMonitorUpdateInput = { + source?: typeof INDICATOR_MONITOR_PROVIDER workspaceId: string workflowId?: string blockId?: string @@ -93,12 +125,47 @@ export type IndicatorMonitorUpdateInput = { isActive?: boolean } -export type IndicatorMonitorStateUpdateInput = { +export type MonitorStateUpdateInput = { + workspaceId: string + isActive: boolean +} + +export type PortfolioMonitorCreateInput = { + source: typeof PORTFOLIO_MONITOR_PROVIDER workspaceId: string + workflowId: string + blockId: string + providerId: string + serviceId: string + credentialId: string + accountId: string + condition: PortfolioFireCondition + fireMode: 'edge' | 'while_true' + cooldownSeconds: number + pollIntervalSeconds: number isActive: boolean } -export type StreamingProviderOption = { +export type PortfolioMonitorUpdateInput = { + source?: typeof PORTFOLIO_MONITOR_PROVIDER + workspaceId: string + workflowId?: string + blockId?: string + providerId?: string + serviceId?: string + credentialId?: string + accountId?: string + condition?: PortfolioFireCondition + fireMode?: 'edge' | 'while_true' + cooldownSeconds?: number + pollIntervalSeconds?: number + isActive?: boolean +} + +export type MonitorCreateInput = IndicatorMonitorCreateInput | PortfolioMonitorCreateInput +export type MonitorUpdateInput = IndicatorMonitorUpdateInput | PortfolioMonitorUpdateInput + +export type TradingProviderOption = { id: string name: string icon?: ComponentType<{ className?: string }> @@ -114,13 +181,18 @@ export type MonitorReferenceData = { workflowTargets: WorkflowTargetOption[] workflowTargetByKey: Record workflowOptions: WorkflowPickerOption[] + indicatorWorkflowTargets: WorkflowTargetOption[] + portfolioWorkflowTargets: WorkflowTargetOption[] indicatorOptions: IndicatorOption[] indicatorById: Record - streamingProviders: StreamingProviderOption[] - providerById: Record + marketProviders: MarketProviderOption[] + marketProviderById: Record providerIntervalsByProviderId: Record providerParamDefinitionsByProviderId: Record - defaultDraftProviderId: string + tradingProviders: TradingProviderOption[] + tradingProviderById: Record + defaultMarketProviderId: string + defaultPortfolioProviderId: string defaultDraftInterval: string createDisabledReason: string | null isLoading: boolean @@ -128,20 +200,20 @@ export type MonitorReferenceData = { } type MonitorRecordMutationOptions = { - optimisticRecord?: IndicatorMonitorRecord + optimisticRecord?: MonitorRecord } export type MonitorRecordActions = { - createMonitor: (input: IndicatorMonitorCreateInput) => Promise + createMonitor: (input: MonitorCreateInput) => Promise updateMonitor: ( monitorId: string, - input: IndicatorMonitorUpdateInput, + input: MonitorUpdateInput, options?: MonitorRecordMutationOptions - ) => Promise + ) => Promise toggleMonitorState: ( - monitor: IndicatorMonitorRecord, + monitor: MonitorRecord, nextIsActive: boolean, options?: MonitorRecordMutationOptions - ) => Promise + ) => Promise deleteMonitor: (monitorId: string) => Promise } diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/shared/utils.ts b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/shared/utils.ts index 36b01794f..858ae5f11 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/shared/utils.ts +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/shared/utils.ts @@ -1,8 +1,21 @@ import type { MarketProviderParamDefinition } from '@/providers/market/providers' -import type { IndicatorMonitorRecord, MonitorDraft } from './types' +import type { MonitorDraft, MonitorRecord } from './types' const toTrimmed = (value: unknown): string => (typeof value === 'string' ? value.trim() : '') +export const DEFAULT_PORTFOLIO_FIRE_CONDITION = { + root: { + combinator: 'and' as const, + rules: [ + { + metric: 'summary.totalPortfolioValue' as const, + operator: 'gt' as const, + value: 0, + }, + ], + }, +} + export const parseErrorMessage = async (response: Response): Promise => { try { const body = await response.json() @@ -50,40 +63,57 @@ const mapProviderParamsToDraftValues = ( ) } -export const buildDraftFromMonitor = (monitor: IndicatorMonitorRecord): MonitorDraft => { +export const buildDraftFromMonitor = (monitor: MonitorRecord): MonitorDraft => { const auth = monitor.providerConfig.monitor.auth + const monitorConfig = monitor.providerConfig.monitor return { + source: monitor.source, workflowId: monitor.workflowId, blockId: monitor.blockId, - providerId: monitor.providerConfig.monitor.providerId, - interval: monitor.providerConfig.monitor.interval, - indicatorId: monitor.providerConfig.monitor.indicatorId, - listing: monitor.providerConfig.monitor.listing, + providerId: monitorConfig.providerId, + interval: monitorConfig.interval ?? '', + indicatorId: monitorConfig.indicatorId ?? '', + listing: monitorConfig.listing ?? null, + serviceId: monitorConfig.serviceId ?? '', + credentialId: monitorConfig.credentialId ?? '', + accountId: monitorConfig.accountId ?? '', + condition: monitorConfig.condition ?? DEFAULT_PORTFOLIO_FIRE_CONDITION, + fireMode: monitorConfig.fireMode ?? 'edge', + cooldownSeconds: monitorConfig.cooldownSeconds ?? 300, + pollIntervalSeconds: monitorConfig.pollIntervalSeconds ?? 60, secretValues: {}, - providerParamValues: mapProviderParamsToDraftValues( - monitor.providerConfig.monitor.providerParams - ), - indicatorInputs: { ...(monitor.providerConfig.monitor.indicatorInputs ?? {}) }, + providerParamValues: mapProviderParamsToDraftValues(monitorConfig.providerParams), + indicatorInputs: { ...(monitorConfig.indicatorInputs ?? {}) }, existingEncryptedSecretFieldIds: auth?.encryptedSecretFieldIds ?? [], isActive: monitor.isActive, } } export const buildDefaultDraft = ({ + source = 'indicator', providerId, interval, }: { + source?: MonitorDraft['source'] providerId: string interval: string }): MonitorDraft => { return { + source, workflowId: '', blockId: '', providerId, interval, indicatorId: '', listing: null, + serviceId: '', + credentialId: '', + accountId: '', + condition: DEFAULT_PORTFOLIO_FIRE_CONDITION, + fireMode: 'edge', + cooldownSeconds: 300, + pollIntervalSeconds: 60, secretValues: {}, providerParamValues: {}, indicatorInputs: {}, diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/timeline/monitor-timeline.test.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/timeline/monitor-timeline.test.tsx index c429cbd3c..0b861001f 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/timeline/monitor-timeline.test.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/timeline/monitor-timeline.test.tsx @@ -73,7 +73,15 @@ describe('MonitorTimeline', () => { workflowName: 'Workflow One', workflowColor: '#3972F6', monitorId: 'monitor-1', + + source: 'indicator', + providerId: 'alpaca', + + serviceId: null, + + accountId: null, + interval: '1m', indicatorId: 'rsi', assetType: 'stock', diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/timeline/timeline-state.test.ts b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/timeline/timeline-state.test.ts index 923e6fb7c..0a5600cb7 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/timeline/timeline-state.test.ts +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/timeline/timeline-state.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import type { MonitorExecutionItem } from '../data/execution-ordering' -import { buildMonitorTimelineGroups } from './timeline-state' import { DEFAULT_EXECUTION_MONITOR_VIEW_CONFIG } from '../view/view-config' +import { buildMonitorTimelineGroups } from './timeline-state' const buildExecution = (overrides: Partial): MonitorExecutionItem => ({ logId: 'log-1', @@ -15,7 +15,10 @@ const buildExecution = (overrides: Partial): MonitorExecut workflowName: 'Workflow One', workflowColor: '#3972F6', monitorId: 'monitor-1', + source: 'indicator', providerId: 'alpaca', + serviceId: null, + accountId: null, interval: '1m', indicatorId: 'rsi', assetType: 'stock', diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/workspace/monitor-config-workspace.test.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/workspace/monitor-config-workspace.test.tsx index bd1c4d99b..e663506fe 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/workspace/monitor-config-workspace.test.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/workspace/monitor-config-workspace.test.tsx @@ -5,7 +5,7 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { IndicatorMonitorRecord, MonitorReferenceData } from '../shared/types' +import type { MonitorRecord, MonitorReferenceData } from '../shared/types' import { DEFAULT_CONFIG_MONITOR_VIEW_CONFIG } from '../view/view-config' import { MonitorConfigWorkspace } from './monitor-config-workspace' @@ -30,6 +30,8 @@ const reactActEnvironment = globalThis as typeof globalThis & { const referenceData: MonitorReferenceData = { workflowTargets: [ { + source: 'indicator', + triggerId: 'indicator_trigger', workflowId: 'workflow-1', blockId: 'block-1', workflowName: 'Workflow One', @@ -41,6 +43,8 @@ const referenceData: MonitorReferenceData = { ], workflowTargetByKey: { 'workflow-1:block-1': { + source: 'indicator', + triggerId: 'indicator_trigger', workflowId: 'workflow-1', blockId: 'block-1', workflowName: 'Workflow One', @@ -51,15 +55,32 @@ const referenceData: MonitorReferenceData = { }, }, workflowOptions: [], + indicatorWorkflowTargets: [ + { + source: 'indicator', + triggerId: 'indicator_trigger', + workflowId: 'workflow-1', + blockId: 'block-1', + workflowName: 'Workflow One', + workflowColor: '#3972F6', + isDeployed: true, + blockName: 'Indicator Trigger', + label: 'Workflow One - Indicator Trigger', + }, + ], + portfolioWorkflowTargets: [], indicatorOptions: [{ id: 'rsi', name: 'RSI', source: 'default', color: '#3972F6' }], indicatorById: { rsi: { id: 'rsi', name: 'RSI', source: 'default', color: '#3972F6' }, }, - streamingProviders: [{ id: 'alpaca', name: 'Alpaca' }], - providerById: { alpaca: { id: 'alpaca', name: 'Alpaca' } }, + marketProviders: [{ id: 'alpaca', name: 'Alpaca' }], + marketProviderById: { alpaca: { id: 'alpaca', name: 'Alpaca' } }, providerIntervalsByProviderId: { alpaca: ['1m'] }, providerParamDefinitionsByProviderId: {}, - defaultDraftProviderId: 'alpaca', + tradingProviders: [{ id: 'alpaca', name: 'Alpaca' }], + tradingProviderById: { alpaca: { id: 'alpaca', name: 'Alpaca' } }, + defaultMarketProviderId: '', + defaultPortfolioProviderId: '', defaultDraftInterval: '1m', createDisabledReason: null, isLoading: false, @@ -101,6 +122,7 @@ const referenceDataWithProviderParams: MonitorReferenceData = { const monitor = { monitorId: 'monitor-1', + source: 'indicator', workflowId: 'workflow-1', blockId: 'block-1', isActive: true, @@ -116,7 +138,7 @@ const monitor = { }, createdAt: '2026-04-23T00:00:00.000Z', updatedAt: '2026-04-24T00:00:00.000Z', -} satisfies IndicatorMonitorRecord +} satisfies MonitorRecord describe('MonitorConfigWorkspace', () => { let container: HTMLDivElement @@ -298,7 +320,6 @@ describe('MonitorConfigWorkspace', () => { ) }) - expect(container.textContent).toContain('All monitors') expect(container.textContent).toContain('Active') expect(container.textContent).toContain('Paused') expect(container.querySelector('button[aria-label^="Add monitor"]')).not.toBeNull() @@ -342,8 +363,8 @@ describe('MonitorConfigWorkspace', () => { }) expect(container.textContent).toContain('Create Monitor') - expect(container.textContent).toContain('Feed') - expect(container.querySelector('input#monitor-secret-apiKey')).not.toBeNull() - expect(container.querySelector('input#monitor-secret-apiSecret')).not.toBeNull() + expect(container.textContent).toContain('Select provider') + expect(container.querySelector('input#monitor-secret-apiKey')).toBeNull() + expect(container.querySelector('input#monitor-secret-apiSecret')).toBeNull() }) }) diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/workspace/monitor-config-workspace.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/workspace/monitor-config-workspace.tsx index 3691af2e4..57582e9b4 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/workspace/monitor-config-workspace.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/workspace/monitor-config-workspace.tsx @@ -24,11 +24,7 @@ import { MonitorControlSelect, MonitorStateCard, } from '../shared/monitor-ui' -import type { - IndicatorMonitorRecord, - MonitorRecordActions, - MonitorReferenceData, -} from '../shared/types' +import type { MonitorRecord, MonitorRecordActions, MonitorReferenceData } from '../shared/types' import { MonitorTimezoneMenu } from '../timezone-selector/monitor-timezone-menu' import { CONFIG_MONITOR_DIMENSION_FIELDS, @@ -50,7 +46,7 @@ type MonitorConfigWorkspaceProps = { viewsError: string | null effectiveConfig: ConfigMonitorViewConfig panelSizes: [number, number] | null - monitorRecords: IndicatorMonitorRecord[] + monitorRecords: MonitorRecord[] monitorsLoading: boolean monitorsError: string | null referenceData: MonitorReferenceData @@ -275,7 +271,6 @@ export function MonitorConfigWorkspace({ workspaceId, draft, originalMonitor: card.sourceMonitor, - referenceData, }), { optimisticRecord } ) @@ -451,6 +446,7 @@ export function MonitorConfigWorkspace({ const hasEditorPanel = editorState.isEditorOpen || Boolean(editorState.selectedMonitor) const editor = hasEditorPanel ? ( { workflowName: 'Workflow One', workflowColor: '#3972F6', monitorId: 'monitor-1', + + source: 'indicator', + providerId: 'alpaca', + + serviceId: null, + + accountId: null, + interval: '1m', indicatorId: 'rsi', assetType: 'stock', @@ -288,7 +296,15 @@ describe('MonitorExecutionWorkspace', () => { workflowName: 'Workflow One', workflowColor: '#3972F6', monitorId: 'monitor-1', + + source: 'indicator', + providerId: null, + + serviceId: null, + + accountId: null, + interval: null, indicatorId: null, assetType: 'stock', @@ -384,7 +400,15 @@ describe('MonitorExecutionWorkspace', () => { workflowName: 'Workflow One', workflowColor: '#3972F6', monitorId: 'monitor-1', + + source: 'indicator', + providerId: 'alpaca', + + serviceId: null, + + accountId: null, + interval: '1m', indicatorId: 'rsi', assetType: 'stock', diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/monitor.test.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/monitor.test.tsx index 2cda7bfdd..33404a3c3 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/monitor.test.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/monitor.test.tsx @@ -7,16 +7,16 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { - createIndicatorMonitor, + createMonitorRecord, createMonitorView, - deleteIndicatorMonitor, + deleteMonitorRecord, loadIndicatorOptions, loadMonitors, loadWorkflowOptions, loadWorkflowTargetOptions, removeMonitorView, setActiveMonitorView, - updateIndicatorMonitor, + updateMonitorRecord, updateMonitorView, } from '@/app/workspace/[workspaceId]/monitor/components/data/api' import { bootstrapMonitorViews } from '@/app/workspace/[workspaceId]/monitor/components/view/view-bootstrap' @@ -259,10 +259,17 @@ vi.mock('@/hooks/queries/logs', async (importOriginal) => { } }) +vi.mock('@/hooks/queries/oauth-provider-availability', () => ({ + fetchOAuthProviderAvailability: vi.fn().mockResolvedValue({ + 'alpaca-paper': true, + 'tradier-live': false, + }), +})) + vi.mock('@/app/workspace/[workspaceId]/monitor/components/data/api', () => ({ createMonitorView: vi.fn(), - createIndicatorMonitor: vi.fn(), - deleteIndicatorMonitor: vi.fn(), + createMonitorRecord: vi.fn(), + deleteMonitorRecord: vi.fn(), listMonitorViews: vi.fn(), loadIndicatorOptions: vi.fn().mockResolvedValue([]), loadMonitors: vi.fn().mockResolvedValue([]), @@ -271,7 +278,7 @@ vi.mock('@/app/workspace/[workspaceId]/monitor/components/data/api', () => ({ removeMonitorView: vi.fn(), reorderMonitorViews: vi.fn(), setActiveMonitorView: vi.fn(), - updateIndicatorMonitor: vi.fn(), + updateMonitorRecord: vi.fn(), updateMonitorView: vi.fn(), })) @@ -280,15 +287,15 @@ vi.mock('@/app/workspace/[workspaceId]/monitor/components/view/view-bootstrap', })) const mockedBootstrapMonitorViews = vi.mocked(bootstrapMonitorViews) -const mockedCreateIndicatorMonitor = vi.mocked(createIndicatorMonitor) +const mockedCreateMonitorRecord = vi.mocked(createMonitorRecord) const mockedCreateMonitorView = vi.mocked(createMonitorView) -const mockedDeleteIndicatorMonitor = vi.mocked(deleteIndicatorMonitor) +const mockedDeleteMonitorRecord = vi.mocked(deleteMonitorRecord) const mockedLoadIndicatorOptions = vi.mocked(loadIndicatorOptions) const mockedLoadMonitors = vi.mocked(loadMonitors) const mockedLoadWorkflowOptions = vi.mocked(loadWorkflowOptions) const mockedLoadWorkflowTargetOptions = vi.mocked(loadWorkflowTargetOptions) const mockedUpdateMonitorView = vi.mocked(updateMonitorView) -const mockedUpdateIndicatorMonitor = vi.mocked(updateIndicatorMonitor) +const mockedUpdateMonitorRecord = vi.mocked(updateMonitorRecord) const mockedRemoveMonitorView = vi.mocked(removeMonitorView) const mockedSetActiveMonitorView = vi.mocked(setActiveMonitorView) @@ -315,6 +322,7 @@ const buildViewRow = ({ const buildMonitorRow = (monitorId: string) => ({ monitorId, + source: 'indicator' as const, workflowId: 'workflow-1', blockId: 'block-1', isActive: true, @@ -384,9 +392,9 @@ describe('MonitorPage', () => { config: { ...DEFAULT_EXECUTION_MONITOR_VIEW_CONFIG, layout: 'timeline' }, }) ) - mockedCreateIndicatorMonitor.mockResolvedValue(buildMonitorRow('monitor-created') as any) - mockedUpdateIndicatorMonitor.mockResolvedValue(buildMonitorRow('monitor-1') as any) - mockedDeleteIndicatorMonitor.mockResolvedValue(undefined) + mockedCreateMonitorRecord.mockResolvedValue(buildMonitorRow('monitor-created') as any) + mockedUpdateMonitorRecord.mockResolvedValue(buildMonitorRow('monitor-1') as any) + mockedDeleteMonitorRecord.mockResolvedValue(undefined) mockedLoadMonitors.mockResolvedValue([]) mockedLoadWorkflowOptions.mockResolvedValue([]) mockedUpdateMonitorView.mockImplementation(async (_workspaceId, viewId, input) => @@ -705,7 +713,7 @@ describe('MonitorPage', () => { const url = new URL(anchor?.href ?? '', 'http://localhost') expect(url.searchParams.get('workspaceId')).toBe('workspace-1') - expect(url.searchParams.get('triggerSource')).toBe('indicator_trigger') + expect(url.searchParams.get('triggerSource')).toBe('indicator_trigger,portfolio_state_trigger') expect(url.searchParams.get('workflowName')).toBe('Workflow One') expect(url.searchParams.get('providerId')).toBe('alpaca') expect(url.searchParams.has('search')).toBe(false) @@ -1010,6 +1018,8 @@ describe('MonitorPage', () => { ]) mockedLoadWorkflowTargetOptions.mockResolvedValueOnce([ { + source: 'indicator', + triggerId: 'indicator_trigger', workflowId: 'workflow-1', blockId: 'block-1', workflowName: 'Workflow One', @@ -1112,7 +1122,7 @@ describe('MonitorPage', () => { await click('config') await click('Create monitor') - expect(mockedCreateIndicatorMonitor).toHaveBeenCalledWith( + expect(mockedCreateMonitorRecord).toHaveBeenCalledWith( expect.objectContaining({ workspaceId: 'workspace-1', workflowId: 'workflow-1', @@ -1120,7 +1130,7 @@ describe('MonitorPage', () => { ) await click('Toggle monitor') - expect(mockedUpdateIndicatorMonitor).toHaveBeenCalledWith( + expect(mockedUpdateMonitorRecord).toHaveBeenCalledWith( 'monitor-1', expect.objectContaining({ workspaceId: 'workspace-1', @@ -1129,7 +1139,7 @@ describe('MonitorPage', () => { ) await click('Delete monitor') - expect(mockedDeleteIndicatorMonitor).toHaveBeenCalledWith('monitor-1') + expect(mockedDeleteMonitorRecord).toHaveBeenCalledWith('monitor-1') }) it('refreshes the full monitor workspace from the page shell', async () => { diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/monitor.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/monitor.tsx index ce2731db3..80150cce1 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/monitor.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/monitor.tsx @@ -19,15 +19,15 @@ import { type LayoutTab, LayoutTabs } from '@/app/workspace/[workspaceId]/dashbo import { buildConfigMonitorCards } from '@/app/workspace/[workspaceId]/monitor/components/config/config-card-model' import { ConfigMonitorSearch } from '@/app/workspace/[workspaceId]/monitor/components/config/config-search' import { - createIndicatorMonitor, + createMonitorRecord, createMonitorView, - deleteIndicatorMonitor, + deleteMonitorRecord, listMonitorViews, loadMonitors, removeMonitorView, reorderMonitorViews, setActiveMonitorView, - updateIndicatorMonitor, + updateMonitorRecord, updateMonitorView, } from '@/app/workspace/[workspaceId]/monitor/components/data/api' import { useMonitorReferenceData } from '@/app/workspace/[workspaceId]/monitor/components/data/use-monitor-reference-data' @@ -38,10 +38,10 @@ import { } from '@/app/workspace/[workspaceId]/monitor/components/data/use-monitor-workspace-logs' import { MonitorStateCard } from '@/app/workspace/[workspaceId]/monitor/components/shared/monitor-ui' import type { - IndicatorMonitorCreateInput, - IndicatorMonitorRecord, - IndicatorMonitorUpdateInput, + MonitorCreateInput, + MonitorRecord, MonitorRecordActions, + MonitorUpdateInput, } from '@/app/workspace/[workspaceId]/monitor/components/shared/types' import { bootstrapMonitorViews } from '@/app/workspace/[workspaceId]/monitor/components/view/view-bootstrap' import { @@ -120,7 +120,7 @@ const normalizeConfigForMode = ( export function MonitorPage({ workspaceId, userId }: MonitorPageProps) { const pathname = usePathname() const workingStateScope = `${workspaceId}:${userId}` - const [monitors, setMonitors] = useState([]) + const [monitors, setMonitors] = useState([]) const [monitorsLoading, setMonitorsLoading] = useState(true) const [monitorsError, setMonitorsError] = useState(null) const referenceData = useMonitorReferenceData(workspaceId) @@ -666,7 +666,7 @@ export function MonitorPage({ workspaceId, userId }: MonitorPageProps) { anchor.remove() }, [executionViewConfig, workspaceId]) - const upsertMonitor = useCallback((nextMonitor: IndicatorMonitorRecord) => { + const upsertMonitor = useCallback((nextMonitor: MonitorRecord) => { setMonitors((current) => [ nextMonitor, ...current.filter((monitor) => monitor.monitorId !== nextMonitor.monitorId), @@ -675,11 +675,11 @@ export function MonitorPage({ workspaceId, userId }: MonitorPageProps) { }, []) const handleCreateMonitor = useCallback( - async (input: IndicatorMonitorCreateInput) => { + async (input: MonitorCreateInput) => { setMonitorsError(null) try { - const savedMonitor = await createIndicatorMonitor(input) + const savedMonitor = await createMonitorRecord(input) if (savedMonitor) { upsertMonitor(savedMonitor) } @@ -696,11 +696,11 @@ export function MonitorPage({ workspaceId, userId }: MonitorPageProps) { const handleUpdateMonitor = useCallback( async ( monitorId: string, - input: IndicatorMonitorUpdateInput, + input: MonitorUpdateInput, options?: Parameters[2] ) => { setMonitorsError(null) - let previousMonitors: IndicatorMonitorRecord[] | null = null + let previousMonitors: MonitorRecord[] | null = null if (options?.optimisticRecord) { setMonitors((current) => { @@ -712,7 +712,7 @@ export function MonitorPage({ workspaceId, userId }: MonitorPageProps) { } try { - const savedMonitor = await updateIndicatorMonitor(monitorId, input) + const savedMonitor = await updateMonitorRecord(monitorId, input) if (savedMonitor) { upsertMonitor(savedMonitor) } @@ -731,12 +731,12 @@ export function MonitorPage({ workspaceId, userId }: MonitorPageProps) { const handleToggleMonitorState = useCallback( async ( - monitor: IndicatorMonitorRecord, + monitor: MonitorRecord, nextIsActive: boolean, options?: Parameters[2] ) => { setMonitorsError(null) - let previousMonitors: IndicatorMonitorRecord[] | null = null + let previousMonitors: MonitorRecord[] | null = null if (options?.optimisticRecord) { setMonitors((current) => { @@ -748,7 +748,7 @@ export function MonitorPage({ workspaceId, userId }: MonitorPageProps) { } try { - const savedMonitor = await updateIndicatorMonitor(monitor.monitorId, { + const savedMonitor = await updateMonitorRecord(monitor.monitorId, { workspaceId, isActive: nextIsActive, }) @@ -772,7 +772,7 @@ export function MonitorPage({ workspaceId, userId }: MonitorPageProps) { setMonitorsError(null) try { - await deleteIndicatorMonitor(monitorId) + await deleteMonitorRecord(monitorId) setMonitors((current) => current.filter((monitor) => monitor.monitorId !== monitorId)) } catch (error) { const message = error instanceof Error ? error.message : 'Failed to delete monitor' diff --git a/apps/tradinggoose/background/indicator-monitor-execution.test.ts b/apps/tradinggoose/background/indicator-monitor-execution.test.ts index 418996647..e5e47b3a4 100644 --- a/apps/tradinggoose/background/indicator-monitor-execution.test.ts +++ b/apps/tradinggoose/background/indicator-monitor-execution.test.ts @@ -3,31 +3,18 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { INDICATOR_MONITOR_PROVIDER } from '@/lib/monitors/sources' import type { IndicatorMonitorExecutionPayload } from './indicator-monitor-execution' const mocks = vi.hoisted(() => ({ - checkServerSideUsageLimits: vi.fn(), + enqueuePendingExecution: vi.fn(), executeCompiledIndicator: vi.fn(), - loadWorkflowExecutionBlueprint: vi.fn(), - runPreparedWorkflowExecution: vi.fn(), })) -vi.mock('@tradinggoose/db', () => ({ - db: { - update: vi.fn(), - }, -})) - -vi.mock('@tradinggoose/db/schema', () => ({ - webhook: { - id: 'webhook.id', - provider: 'webhook.provider', - }, -})) - -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })), - eq: vi.fn((field: unknown, value: unknown) => ({ field, type: 'eq', value })), +vi.mock('@/lib/execution/pending-execution', () => ({ + enqueuePendingExecution: (...args: unknown[]) => mocks.enqueuePendingExecution(...args), + isPendingExecutionLimitError: (error: { code?: string }) => + error?.code === 'PENDING_EXECUTION_LIMIT', })) vi.mock('@/lib/indicators/dispatch', () => ({ @@ -54,21 +41,12 @@ vi.mock('@/lib/indicators/series-data', () => ({ normalizeBarsMs: vi.fn((bars) => bars), })) -vi.mock('@/lib/billing', () => ({ - checkServerSideUsageLimits: (...args: unknown[]) => mocks.checkServerSideUsageLimits(...args), -})) - vi.mock('@/lib/logs/console/logger', () => ({ createLogger: vi.fn(() => ({ error: vi.fn(), info: vi.fn(), warn: vi.fn() })), })) -vi.mock('@/lib/workflows/execution-runner', () => ({ - loadWorkflowExecutionBlueprint: (...args: unknown[]) => - mocks.loadWorkflowExecutionBlueprint(...args), - runPreparedWorkflowExecution: (...args: unknown[]) => mocks.runPreparedWorkflowExecution(...args), -})) - const payload = { + source: INDICATOR_MONITOR_PROVIDER, monitor: { id: 'monitor-1', workflowId: 'workflow-1', @@ -99,23 +77,19 @@ const payload = { describe('executeIndicatorMonitorJob', () => { beforeEach(() => { vi.clearAllMocks() - mocks.checkServerSideUsageLimits.mockResolvedValue({ isExceeded: false }) + mocks.enqueuePendingExecution.mockResolvedValue({ + billingScopeId: 'workspace-1', + inserted: true, + pendingExecutionId: 'event-1', + }) mocks.executeCompiledIndicator.mockResolvedValue({ output: { triggers: [{ event: 'cross', signal: 'buy', time: 1 }], }, }) - mocks.loadWorkflowExecutionBlueprint.mockResolvedValue({ - workflowData: { - blocks: { 'trigger-block': {} }, - }, - }) - mocks.runPreparedWorkflowExecution.mockResolvedValue({ - result: { success: true, output: { ok: true } }, - }) }) - it('rejects missing workspace scope before usage checks', async () => { + it('rejects missing workspace scope before queueing workflow execution', async () => { const { executeIndicatorMonitorJob } = await import('./indicator-monitor-execution') await expect( @@ -125,23 +99,54 @@ describe('executeIndicatorMonitorJob', () => { }) ).rejects.toThrow('Indicator monitor execution requires workspaceId') - expect(mocks.checkServerSideUsageLimits).not.toHaveBeenCalled() + expect(mocks.enqueuePendingExecution).not.toHaveBeenCalled() }) - it('passes the resolved workspace scope into usage and blueprint loading', async () => { + it('queues triggered workflow execution with the indicator event as the workflow dedupe key', async () => { const { executeIndicatorMonitorJob } = await import('./indicator-monitor-execution') - await executeIndicatorMonitorJob(payload) + const result = await executeIndicatorMonitorJob(payload) - expect(mocks.checkServerSideUsageLimits).toHaveBeenCalledWith( + expect(result).toMatchObject({ + success: true, + executionId: 'event-1', + }) + expect(mocks.enqueuePendingExecution).toHaveBeenCalledWith( expect.objectContaining({ + executionType: 'workflow', + pendingExecutionId: 'event-1', + orderingKey: 'monitor:monitor-1', + source: 'monitor:indicator', workspaceId: 'workspace-1', + payload: expect.objectContaining({ + executionId: 'event-1', + workflowId: 'workflow-1', + userId: 'actor-1', + workspaceId: 'workspace-1', + triggerType: 'webhook', + executionTarget: 'deployed', + startBlockId: 'trigger-block', + }), }) ) - expect(mocks.loadWorkflowExecutionBlueprint).toHaveBeenCalledWith({ - executionTarget: 'deployed', - workflowContext: { workspaceId: 'workspace-1' }, - workflowId: 'workflow-1', + }) + + it('completes indicator calculation when the workflow backlog is full', async () => { + const { executeIndicatorMonitorJob } = await import('./indicator-monitor-execution') + mocks.enqueuePendingExecution.mockRejectedValueOnce({ + code: 'PENDING_EXECUTION_LIMIT', + details: { + pendingCount: 100, + maxPendingCount: 100, + }, + }) + + const result = await executeIndicatorMonitorJob(payload) + + expect(result).toMatchObject({ + success: true, + skipped: 'workflow_backlog_full', + executionId: 'event-1', }) }) }) diff --git a/apps/tradinggoose/background/indicator-monitor-execution.ts b/apps/tradinggoose/background/indicator-monitor-execution.ts index 883c83c85..8391b64ca 100644 --- a/apps/tradinggoose/background/indicator-monitor-execution.ts +++ b/apps/tradinggoose/background/indicator-monitor-execution.ts @@ -1,7 +1,7 @@ -import { db } from '@tradinggoose/db' -import { webhook } from '@tradinggoose/db/schema' -import { and, eq } from 'drizzle-orm' -import { checkServerSideUsageLimits } from '@/lib/billing' +import { + enqueuePendingExecution, + isPendingExecutionLimitError, +} from '@/lib/execution/pending-execution' import { applyIndicatorTriggerPayloadBudget, buildIndicatorTriggerDispatchPayload, @@ -13,10 +13,7 @@ import { normalizeBarsMs } from '@/lib/indicators/series-data' import type { BarMs, NormalizedPineSignal } from '@/lib/indicators/types' import type { ListingIdentity } from '@/lib/listing/identity' import { createLogger } from '@/lib/logs/console/logger' -import { - loadWorkflowExecutionBlueprint, - runPreparedWorkflowExecution, -} from '@/lib/workflows/execution-runner' +import { INDICATOR_MONITOR_PROVIDER, INDICATOR_MONITOR_TRIGGER_ID } from '@/lib/monitors/sources' import type { MarketSeries } from '@/providers/market/types' const logger = createLogger('IndicatorMonitorExecution') @@ -28,7 +25,7 @@ type IndicatorMonitorExecutionMonitor = { userId: string actorUserId: string blockId: string - providerId: 'alpaca' | 'finnhub' + providerId: string interval: string intervalMs: number | null indicatorId: string @@ -43,6 +40,7 @@ type IndicatorMonitorExecutionIndicator = { export type IndicatorMonitorExecutionPayload = { executionId?: string + source: typeof INDICATOR_MONITOR_PROVIDER monitor: IndicatorMonitorExecutionMonitor indicator: IndicatorMonitorExecutionIndicator inputsMap: Record @@ -66,7 +64,8 @@ const isMonitor = (value: unknown): value is IndicatorMonitorExecutionMonitor => typeof value.userId === 'string' && typeof value.actorUserId === 'string' && typeof value.blockId === 'string' && - (value.providerId === 'alpaca' || value.providerId === 'finnhub') && + typeof value.providerId === 'string' && + value.providerId.trim().length > 0 && typeof value.interval === 'string' && (typeof value.intervalMs === 'number' || value.intervalMs === null) && typeof value.indicatorId === 'string' && @@ -94,6 +93,7 @@ export function isIndicatorMonitorExecutionPayload( } return ( + value.source === INDICATOR_MONITOR_PROVIDER && isMonitor(value.monitor) && isIndicator(value.indicator) && isRecord(value.inputsMap) && @@ -159,26 +159,6 @@ const chooseCandidate = ({ ) } -async function disableMonitor( - monitorId: string, - reason: string, - metadata: Record = {} -) { - await db - .update(webhook) - .set({ - isActive: false, - updatedAt: new Date(), - }) - .where(and(eq(webhook.id, monitorId), eq(webhook.provider, 'indicator'))) - - logger.warn('Indicator monitor disabled', { - monitorId, - reason, - ...metadata, - }) -} - export async function executeIndicatorMonitorJob(payload: IndicatorMonitorExecutionPayload) { const requestId = (payload.executionId ?? payload.monitor.id).slice(0, 8) const workspaceId = payload.monitor.workspaceId.trim() @@ -275,73 +255,68 @@ export async function executeIndicatorMonitorJob(payload: IndicatorMonitorExecut }) } - const usageCheck = await checkServerSideUsageLimits({ - userId: payload.monitor.actorUserId, + const handle = await enqueuePendingExecution({ + executionType: 'workflow', + pendingExecutionId: eventId, workflowId: payload.monitor.workflowId, workspaceId, - }) - if (usageCheck.isExceeded) { - await disableMonitor(payload.monitor.id, 'usage_limit_exceeded', { - workflowId: payload.monitor.workflowId, - currentUsage: usageCheck.currentUsage, - limit: usageCheck.limit, - }) - return { success: true, skipped: 'usage_limit_exceeded' as const } - } - - const blueprint = await loadWorkflowExecutionBlueprint({ - workflowId: payload.monitor.workflowId, - executionTarget: 'deployed', - workflowContext: { workspaceId }, - }) - const blocks = blueprint.workflowData.blocks as Record - if (!blocks[payload.monitor.blockId]) { - await disableMonitor(payload.monitor.id, 'missing_trigger_block', { + userId: payload.monitor.actorUserId, + source: 'monitor:indicator', + orderingKey: `monitor:${payload.monitor.id}`, + requestId: eventId, + payload: { + executionId: eventId, workflowId: payload.monitor.workflowId, - blockId: payload.monitor.blockId, - }) - return { success: true, skipped: 'missing_trigger_block' as const } - } - - const { result } = await runPreparedWorkflowExecution({ - blueprint, - actorUserId: payload.monitor.actorUserId, - requestId, - executionId: eventId, - triggerType: 'webhook', - workflowInput: budgetResult.payload, - start: { - kind: 'block', - blockId: payload.monitor.blockId, - }, - triggerData: { - source: 'indicator_trigger', + userId: payload.monitor.actorUserId, + workspaceId, + input: budgetResult.payload, + triggerType: 'webhook', executionTarget: 'deployed', - monitor: { - id: payload.monitor.id, - workflowId: payload.monitor.workflowId, - blockId: payload.monitor.blockId, - listing: payload.monitor.listing, - providerId: payload.monitor.providerId, - interval: payload.monitor.interval, - indicatorId: payload.monitor.indicatorId, + startBlockId: payload.monitor.blockId, + triggerData: { + source: INDICATOR_MONITOR_TRIGGER_ID, + executionTarget: 'deployed', + monitor: { + id: payload.monitor.id, + workflowId: payload.monitor.workflowId, + blockId: payload.monitor.blockId, + listing: payload.monitor.listing, + providerId: payload.monitor.providerId, + interval: payload.monitor.interval, + indicatorId: payload.monitor.indicatorId, + }, }, }, + }).catch((error) => { + if (!isPendingExecutionLimitError(error)) throw error + logger.warn('Indicator monitor workflow queue backlog is full; skipping workflow dispatch', { + monitorId: payload.monitor.id, + workflowId: payload.monitor.workflowId, + pendingCount: error.details.pendingCount, + maxPendingCount: error.details.maxPendingCount, + }) + return null }) + if (!handle) { + return { success: true, skipped: 'workflow_backlog_full' as const, executionId: eventId } + } + + if (!handle.inserted) { + return { success: true, skipped: 'duplicate_workflow' as const, executionId: eventId } + } + logger.info(`[${requestId}] Indicator monitor execution completed`, { - success: result.success, + success: true, monitorId: payload.monitor.id, workflowId: payload.monitor.workflowId, }) return { - success: result.success, + success: true, workflowId: payload.monitor.workflowId, executionId: eventId, - output: result.output, - error: result.error, executedAt: new Date().toISOString(), - provider: 'indicator', + provider: INDICATOR_MONITOR_PROVIDER, } } diff --git a/apps/tradinggoose/background/monitor-disable.ts b/apps/tradinggoose/background/monitor-disable.ts new file mode 100644 index 000000000..049d93aba --- /dev/null +++ b/apps/tradinggoose/background/monitor-disable.ts @@ -0,0 +1,33 @@ +import { db, webhook } from '@tradinggoose/db' +import { and, eq } from 'drizzle-orm' +import type { MonitorWebhookProvider } from '@/lib/monitors/sources' + +type MonitorDisableLogger = { + warn: (message: string, metadata?: Record) => void +} + +export async function disableMonitor({ + monitorId, + provider, + logger, + ...metadata +}: { + monitorId: string + provider: MonitorWebhookProvider + logger: MonitorDisableLogger + [key: string]: unknown +}) { + await db + .update(webhook) + .set({ + isActive: false, + updatedAt: new Date(), + }) + .where(and(eq(webhook.id, monitorId), eq(webhook.provider, provider))) + + logger.warn('Monitor disabled', { + monitorId, + provider, + ...metadata, + }) +} diff --git a/apps/tradinggoose/background/monitor-execution.ts b/apps/tradinggoose/background/monitor-execution.ts new file mode 100644 index 000000000..887b1b35b --- /dev/null +++ b/apps/tradinggoose/background/monitor-execution.ts @@ -0,0 +1,39 @@ +import { INDICATOR_MONITOR_PROVIDER, PORTFOLIO_MONITOR_PROVIDER } from '@/lib/monitors/sources' +import { + executeIndicatorMonitorJob, + type IndicatorMonitorExecutionPayload, + isIndicatorMonitorExecutionPayload, +} from './indicator-monitor-execution' +import { + executePortfolioMonitorJob, + isPortfolioMonitorExecutionPayload, + type PortfolioMonitorExecutionPayload, +} from './portfolio-monitor-execution' + +export type MonitorExecutionPayload = + | IndicatorMonitorExecutionPayload + | PortfolioMonitorExecutionPayload + +const monitorExecutionHandlers = { + [INDICATOR_MONITOR_PROVIDER]: { + isPayload: isIndicatorMonitorExecutionPayload, + execute: executeIndicatorMonitorJob, + }, + [PORTFOLIO_MONITOR_PROVIDER]: { + isPayload: isPortfolioMonitorExecutionPayload, + execute: executePortfolioMonitorJob, + }, +} as const + +export function isMonitorExecutionPayload(value: unknown): value is MonitorExecutionPayload { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + const source = (value as { source?: unknown }).source + if (typeof source !== 'string') return false + const handler = monitorExecutionHandlers[source as keyof typeof monitorExecutionHandlers] + return handler ? handler.isPayload(value) : false +} + +export async function executeMonitorJob(payload: MonitorExecutionPayload) { + const handler = monitorExecutionHandlers[payload.source] + return handler.execute(payload as never) +} diff --git a/apps/tradinggoose/background/pending-execution-drain.test.ts b/apps/tradinggoose/background/pending-execution-drain.test.ts index 49ae5fb76..cf76bbf51 100644 --- a/apps/tradinggoose/background/pending-execution-drain.test.ts +++ b/apps/tradinggoose/background/pending-execution-drain.test.ts @@ -2,18 +2,19 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { INDICATOR_MONITOR_PROVIDER } from '@/lib/monitors/sources' const { dispatchQueuedDocumentProcessingJobMock, executeWorkflowJobMock, - executeIndicatorMonitorJobMock, + executeMonitorJobMock, claimNextPendingExecutionMock, completePendingExecutionMock, failQueuedDocumentProcessingJobMock, } = vi.hoisted(() => ({ dispatchQueuedDocumentProcessingJobMock: vi.fn(), executeWorkflowJobMock: vi.fn(), - executeIndicatorMonitorJobMock: vi.fn(), + executeMonitorJobMock: vi.fn(), claimNextPendingExecutionMock: vi.fn(), completePendingExecutionMock: vi.fn(), failQueuedDocumentProcessingJobMock: vi.fn(), @@ -40,9 +41,9 @@ vi.mock('./knowledge-processing', () => ({ failQueuedDocumentProcessingJob: failQueuedDocumentProcessingJobMock, })) -vi.mock('./indicator-monitor-execution', () => ({ - executeIndicatorMonitorJob: executeIndicatorMonitorJobMock, - isIndicatorMonitorExecutionPayload: vi.fn(() => false), +vi.mock('./monitor-execution', () => ({ + executeMonitorJob: executeMonitorJobMock, + isMonitorExecutionPayload: vi.fn(() => false), })) vi.mock('./schedule-execution', () => ({ @@ -223,11 +224,12 @@ describe('pendingExecutionDrain', () => { id: 'pending-indicator-1', billingScopeId: 'scope-1', billingScopeType: 'user', - executionType: 'indicator_monitor', + executionType: 'monitor', userId: 'actor-1', workflowId: 'workflow-1', workspaceId: 'workspace-1', payload: { + source: INDICATOR_MONITOR_PROVIDER, monitor: { id: 'monitor-1', workflowId: 'workflow-1', @@ -257,13 +259,13 @@ describe('pendingExecutionDrain', () => { }, }) - const { isIndicatorMonitorExecutionPayload } = await import('./indicator-monitor-execution') - vi.mocked(isIndicatorMonitorExecutionPayload).mockReturnValue(true) - executeIndicatorMonitorJobMock.mockResolvedValue({ success: true }) + const { isMonitorExecutionPayload } = await import('./monitor-execution') + vi.mocked(isMonitorExecutionPayload).mockReturnValue(true) + executeMonitorJobMock.mockResolvedValue({ success: true }) const result = await runPendingExecutionDrain('scope-1') - expect(executeIndicatorMonitorJobMock).toHaveBeenCalledWith( + expect(executeMonitorJobMock).toHaveBeenCalledWith( expect.objectContaining({ executionId: 'pending-indicator-1', }) diff --git a/apps/tradinggoose/background/pending-execution-drain.ts b/apps/tradinggoose/background/pending-execution-drain.ts index 5e77c01c4..aa653fdd1 100644 --- a/apps/tradinggoose/background/pending-execution-drain.ts +++ b/apps/tradinggoose/background/pending-execution-drain.ts @@ -6,14 +6,11 @@ import { type PendingExecutionClaim, } from '@/lib/execution/pending-execution' import { createLogger } from '@/lib/logs/console/logger' -import { - executeIndicatorMonitorJob, - isIndicatorMonitorExecutionPayload, -} from './indicator-monitor-execution' import { dispatchQueuedDocumentProcessingJob, failQueuedDocumentProcessingJob, } from './knowledge-processing' +import { executeMonitorJob, isMonitorExecutionPayload } from './monitor-execution' import { executeScheduleJob, isScheduleExecutionPayload } from './schedule-execution' import { executeWebhookJob, isWebhookExecutionPayload } from './webhook-execution' import { executeWorkflowJob, isWorkflowExecutionPayload } from './workflow-execution' @@ -62,12 +59,12 @@ async function dispatchPendingExecution(row: PendingExecutionClaim): Promise => + Boolean(value && typeof value === 'object' && !Array.isArray(value)) + +export function isPortfolioMonitorExecutionPayload( + value: unknown +): value is PortfolioMonitorExecutionPayload { + if (!isRecord(value)) return false + const monitor = value.monitor + return ( + value.source === PORTFOLIO_MONITOR_PROVIDER && + isRecord(monitor) && + typeof monitor.id === 'string' && + typeof monitor.workflowId === 'string' && + typeof monitor.workspaceId === 'string' && + typeof monitor.actorUserId === 'string' && + typeof monitor.blockId === 'string' && + isRecord(value.portfolioIdentity) && + isRecord(value.portfolioDetail) + ) +} + +export async function executePortfolioMonitorJob(payload: PortfolioMonitorExecutionPayload) { + const executionId = payload.executionId ?? `portfolio_state:${payload.monitor.id}:${Date.now()}` + const requestId = executionId.slice(0, 8) + const workflowInput = { + input: `Portfolio state condition matched for ${payload.portfolioIdentity.accountName ?? payload.portfolioIdentity.accountId}`, + event: 'portfolio_state_condition_matched', + portfolio: { + identity: payload.portfolioIdentity, + detail: payload.portfolioDetail, + }, + monitor: { + id: payload.monitor.id, + workflowId: payload.monitor.workflowId, + blockId: payload.monitor.blockId, + providerId: payload.monitor.providerId, + serviceId: payload.monitor.serviceId, + accountId: payload.monitor.accountId, + }, + condition: payload.monitor.condition, + } + + const { result, dispatchFailureReason } = await runWorkflowExecution({ + workflowId: payload.monitor.workflowId, + actorUserId: payload.monitor.actorUserId, + requestId, + executionId, + triggerType: 'webhook', + workflowInput, + executionTarget: 'deployed', + workflowContext: { workspaceId: payload.monitor.workspaceId }, + start: { + kind: 'block', + blockId: payload.monitor.blockId, + }, + triggerData: { + source: PORTFOLIO_MONITOR_TRIGGER_ID, + executionTarget: 'deployed', + monitor: { + id: payload.monitor.id, + workflowId: payload.monitor.workflowId, + blockId: payload.monitor.blockId, + providerId: payload.monitor.providerId, + serviceId: payload.monitor.serviceId, + accountId: payload.monitor.accountId, + }, + }, + }) + if (dispatchFailureReason) { + await disableMonitor({ + monitorId: payload.monitor.id, + provider: PORTFOLIO_MONITOR_PROVIDER, + logger, + reason: dispatchFailureReason, + workflowId: payload.monitor.workflowId, + blockId: payload.monitor.blockId, + }) + } + + return { + success: result.success, + workflowId: payload.monitor.workflowId, + executionId, + output: result.output, + error: result.error, + executedAt: new Date().toISOString(), + provider: PORTFOLIO_MONITOR_PROVIDER, + } +} diff --git a/apps/tradinggoose/background/webhook-execution.ts b/apps/tradinggoose/background/webhook-execution.ts index 41966359c..3c4372f59 100644 --- a/apps/tradinggoose/background/webhook-execution.ts +++ b/apps/tradinggoose/background/webhook-execution.ts @@ -2,7 +2,6 @@ import { db } from '@tradinggoose/db' import { webhook } from '@tradinggoose/db/schema' import { eq } from 'drizzle-orm' import { v4 as uuidv4 } from 'uuid' -import { toListingValueObject } from '@/lib/listing/identity' import { createLogger } from '@/lib/logs/console/logger' import { LoggingSession } from '@/lib/logs/execution/logging-session' import { WebhookAttachmentProcessor } from '@/lib/webhooks/attachment-processor' @@ -69,9 +68,6 @@ export type WebhookExecutionPayload = { executionTarget?: 'deployed' | 'live' } -const isRecord = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value) - export function isWebhookExecutionPayload(value: unknown): value is WebhookExecutionPayload { if (!value || typeof value !== 'object') { return false @@ -86,50 +82,6 @@ export function isWebhookExecutionPayload(value: unknown): value is WebhookExecu ) } -const toTrimmedString = (value: unknown): string | null => { - if (typeof value !== 'string') return null - const trimmed = value.trim() - return trimmed.length > 0 ? trimmed : null -} - -const buildIndicatorTriggerData = ( - payload: WebhookExecutionPayload -): Record | null => { - if (payload.provider !== 'indicator') return null - if (!isRecord(payload.body)) { - return { source: 'indicator_trigger' } - } - - const monitorRaw = payload.body.monitor - if (!isRecord(monitorRaw)) { - return { source: 'indicator_trigger' } - } - - const listing = toListingValueObject(monitorRaw.listing as any) - const monitor = { - id: toTrimmedString(monitorRaw.id), - workflowId: toTrimmedString(monitorRaw.workflowId), - blockId: toTrimmedString(monitorRaw.blockId), - providerId: toTrimmedString(monitorRaw.providerId), - interval: toTrimmedString(monitorRaw.interval), - indicatorId: toTrimmedString(monitorRaw.indicatorId), - } - - const monitorMetadata = Object.fromEntries( - Object.entries(monitor).filter(([, value]) => typeof value === 'string' && value.length > 0) - ) - - return { - source: 'indicator_trigger', - monitor: listing - ? { - ...monitorMetadata, - listing, - } - : monitorMetadata, - } -} - async function completeSkippedWebhookExecution(params: { payload: WebhookExecutionPayload executionId: string @@ -222,11 +174,9 @@ export async function executeWebhookJob(payload: WebhookExecutionPayload) { executionId, }) - const indicatorTriggerData = buildIndicatorTriggerData(payload) const triggerData = { isTest: payload.testMode === true, executionTarget, - ...(indicatorTriggerData ?? {}), } let runnerInvoked = false diff --git a/apps/tradinggoose/background/workflow-execution.test.ts b/apps/tradinggoose/background/workflow-execution.test.ts index 08c156bc0..c354156b5 100644 --- a/apps/tradinggoose/background/workflow-execution.test.ts +++ b/apps/tradinggoose/background/workflow-execution.test.ts @@ -9,12 +9,14 @@ const { createWorkflowExecutionEventWriterMock, writeExecutionEventMock, isPendingWorkflowExecutionCancellationRequestedMock, + disableMonitorMock, } = vi.hoisted(() => ({ runWorkflowExecutionMock: vi.fn(), buildTraceSpansMock: vi.fn(), createWorkflowExecutionEventWriterMock: vi.fn(), writeExecutionEventMock: vi.fn(), isPendingWorkflowExecutionCancellationRequestedMock: vi.fn(), + disableMonitorMock: vi.fn(), })) vi.mock('@/lib/execution/workflow-execution-events', () => ({ @@ -40,6 +42,10 @@ vi.mock('@/lib/logs/console/logger', () => ({ })), })) +vi.mock('./monitor-disable', () => ({ + disableMonitor: disableMonitorMock, +})) + import { executeWorkflowJob } from './workflow-execution' describe('executeWorkflowJob', () => { @@ -205,4 +211,36 @@ describe('executeWorkflowJob', () => { expect(isPendingWorkflowExecutionCancellationRequestedMock).toHaveBeenCalledWith('execution-1') }) + + it('disables monitor workflow sources after permanent dispatch failures', async () => { + runWorkflowExecutionMock.mockResolvedValueOnce({ + dispatchFailureReason: 'usage_limit_exceeded', + result: { + success: false, + output: {}, + error: 'Usage limit exceeded', + metadata: { duration: 0 }, + }, + }) + + await executeWorkflowJob({ + workflowId: 'workflow-1', + userId: 'user-1', + triggerType: 'webhook', + startBlockId: 'trigger-1', + triggerData: { + source: 'indicator_trigger', + monitor: { id: 'monitor-1' }, + }, + }) + + expect(disableMonitorMock).toHaveBeenCalledWith( + expect.objectContaining({ + monitorId: 'monitor-1', + provider: 'indicator', + reason: 'usage_limit_exceeded', + workflowId: 'workflow-1', + }) + ) + }) }) diff --git a/apps/tradinggoose/background/workflow-execution.ts b/apps/tradinggoose/background/workflow-execution.ts index 002289333..23d17e114 100644 --- a/apps/tradinggoose/background/workflow-execution.ts +++ b/apps/tradinggoose/background/workflow-execution.ts @@ -3,6 +3,7 @@ import { isPendingWorkflowExecutionCancellationRequested } from '@/lib/execution import { createWorkflowExecutionEventWriter } from '@/lib/execution/workflow-execution-events' import { createLogger } from '@/lib/logs/console/logger' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' +import { getMonitorProviderForTriggerId, isMonitorTriggerId } from '@/lib/monitors/sources' import { createWorkflowExecutionTerminalEventInput } from '@/lib/workflows/execution-events' import { runWorkflowExecution, @@ -10,6 +11,7 @@ import { type WorkflowStart, } from '@/lib/workflows/execution-runner' import type { TriggerType } from '@/services/queue' +import { disableMonitor } from './monitor-disable' const logger = createLogger('TriggerWorkflowExecution') @@ -66,16 +68,15 @@ export async function executeWorkflowJob(payload: WorkflowExecutionPayload) { const isLiveExecution = executionTarget === 'live' const isChildExecution = payload.metadata?.source === 'workflow_block' const triggerType = payload.triggerType ?? 'manual' - const start: WorkflowStart = - isLiveExecution && payload.startBlockId - ? { - kind: 'block', - blockId: payload.startBlockId, - } - : { - kind: 'trigger', - triggerType: resolveWorkflowStartTriggerType(triggerType), - } + const start: WorkflowStart = payload.startBlockId + ? { + kind: 'block', + blockId: payload.startBlockId, + } + : { + kind: 'trigger', + triggerType: resolveWorkflowStartTriggerType(triggerType), + } logger.info(`[${requestId}] Starting workflow execution: ${workflowId}`, { userId: payload.userId, @@ -95,7 +96,7 @@ export async function executeWorkflowJob(payload: WorkflowExecutionPayload) { payload.metadata === undefined ? payload.triggerData : { ...(payload.triggerData ?? {}), queuedExecution: payload.metadata } - const { result } = await runWorkflowExecution({ + const { result, dispatchFailureReason } = await runWorkflowExecution({ workflowId, actorUserId: payload.userId, requestId, @@ -128,6 +129,18 @@ export async function executeWorkflowJob(payload: WorkflowExecutionPayload) { : {}), }, }) + if (dispatchFailureReason && isMonitorTriggerId(triggerData?.source)) { + const monitorId = (triggerData.monitor as { id?: unknown } | null | undefined)?.id + if (typeof monitorId === 'string') { + await disableMonitor({ + monitorId, + provider: getMonitorProviderForTriggerId(triggerData.source), + logger, + reason: dispatchFailureReason, + workflowId, + }) + } + } const { traceSpans } = buildTraceSpans(result) const queuedResult = { diff --git a/apps/tradinggoose/blocks/blocks/historical_data.ts b/apps/tradinggoose/blocks/blocks/historical_data.ts index e435f6f61..8b0fce717 100644 --- a/apps/tradinggoose/blocks/blocks/historical_data.ts +++ b/apps/tradinggoose/blocks/blocks/historical_data.ts @@ -4,7 +4,6 @@ import type { BlockConfig, SubBlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' import { coerceMarketProviderParamValue, - getMarketProviderOptionsByKind, getMarketProviderParamCatalog, getMarketProvidersByKind, getMarketSeriesCapabilities, @@ -18,12 +17,6 @@ interface HistoricalDataResponse extends ToolResponse { output: MarketSeriesOutput } -const providerOptions = () => - getMarketProviderOptionsByKind('series').map((provider) => ({ - label: provider.name, - id: provider.id, - })) - const resolveContextValue = ( contextValues: Record | undefined, key: string @@ -232,10 +225,9 @@ export const HistoricalDataBlock: BlockConfig = { { id: 'provider', title: 'Data Provider', - type: 'dropdown', + type: 'market-provider-selector', layout: 'full', - options: providerOptions, - value: () => providerOptions()[0]?.id, + marketProviderKind: 'series', required: true, }, { diff --git a/apps/tradinggoose/blocks/blocks/trading_holdings.ts b/apps/tradinggoose/blocks/blocks/portfolio_detail.ts similarity index 51% rename from apps/tradinggoose/blocks/blocks/trading_holdings.ts rename to apps/tradinggoose/blocks/blocks/portfolio_detail.ts index 7608bc593..fa5e06b2c 100644 --- a/apps/tradinggoose/blocks/blocks/trading_holdings.ts +++ b/apps/tradinggoose/blocks/blocks/portfolio_detail.ts @@ -1,23 +1,17 @@ import { DollarIcon } from '@/components/icons/icons' import type { BlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' -import { - fetchTradingPortfolioIdentityOptions, - fetchTradingProviderOptionsByKind, - requiredUserOnlyInput, -} from '@/blocks/utils' +import { requiredUserOnlyInput } from '@/blocks/utils' import { toPortfolioValueObject } from '@/providers/trading/portfolio-identity' -import type { TradingHoldingsResponse } from '@/providers/trading/types' +import type { TradingPortfolioDetailResponse } from '@/providers/trading/types' -const fetchHoldingsProviderOptions = fetchTradingProviderOptionsByKind('holdings') - -export const TradingHoldingsBlock: BlockConfig = { - type: 'trading_holdings', - name: 'Trading Holdings', - description: 'Fetch canonical portfolio detail from supported brokers.', +export const TradingPortfolioDetailBlock: BlockConfig = { + type: 'portfolio_detail', + name: 'Portfolio Detail', + description: 'Fetch full portfolio detail from a selected broker account.', authMode: AuthMode.OAuth, longDescription: - 'Trading holdings block that returns canonical portfolio detail for Alpaca or Tradier.', + 'Portfolio detail block that returns account summary, cash, positions, and orders from Alpaca or Tradier.', category: 'tools', bgColor: '#115e59', icon: DollarIcon, @@ -25,31 +19,29 @@ export const TradingHoldingsBlock: BlockConfig = { { id: 'provider', title: 'Broker', - type: 'dropdown', + type: 'trading-provider-selector', layout: 'full', - options: [], - fetchOptions: fetchHoldingsProviderOptions, + tradingProviderKind: 'portfolioDetail', placeholder: 'Select broker', required: true, }, { id: 'portfolioIdentity', title: 'Broker Account', - type: 'dropdown', + type: 'trading-account-selector', layout: 'full', required: true, dependsOn: ['provider'], - enableSearch: true, autoSelectFirstOption: false, placeholder: 'Select broker account', - description: 'Broker account used to fetch canonical portfolio detail.', - fetchOptions: fetchTradingPortfolioIdentityOptions, + description: 'Broker account used to fetch portfolio detail.', + tradingProviderFieldId: 'provider', }, ], tools: { - access: ['trading_get_holdings'], + access: ['trading_get_portfolio_detail'], config: { - tool: () => 'trading_get_holdings', + tool: () => 'trading_get_portfolio_detail', params: (params) => { const portfolioIdentity = toPortfolioValueObject(params.portfolioIdentity) return { @@ -65,8 +57,8 @@ export const TradingHoldingsBlock: BlockConfig = { ), }, outputs: { - summary: { type: 'string', description: 'Status of holdings retrieval' }, + summary: { type: 'string', description: 'Status of portfolio detail retrieval' }, provider: { type: 'string', description: 'Provider used' }, - holdings: { type: 'json', description: 'Canonical portfolio detail payload' }, + portfolioDetail: { type: 'json', description: 'Canonical portfolio detail payload' }, }, } diff --git a/apps/tradinggoose/blocks/blocks/trading_action.ts b/apps/tradinggoose/blocks/blocks/trading_action.ts index 8053c417d..3220c4089 100644 --- a/apps/tradinggoose/blocks/blocks/trading_action.ts +++ b/apps/tradinggoose/blocks/blocks/trading_action.ts @@ -2,12 +2,7 @@ import { DollarIcon } from '@/components/icons/icons' import type { ListingInputValue } from '@/lib/listing/identity' import type { BlockConfig, SubBlockCondition } from '@/blocks/types' import { AuthMode } from '@/blocks/types' -import { - buildInputsFromToolParams, - fetchTradingPortfolioIdentityOptions, - fetchTradingProviderOptionsByKind, - requiredUserOnlyInput, -} from '@/blocks/utils' +import { buildInputsFromToolParams, requiredUserOnlyInput } from '@/blocks/utils' import { getTradingOrderSizingModeDefinitions, getTradingOrderTimeInForceOptions, @@ -33,7 +28,6 @@ const resolveContextValue = ( } const orderProviders = getTradingProvidersByKind('order') -const fetchOrderProviderOptions = fetchTradingProviderOptionsByKind('order') const providerIdsWith = (predicate: (provider: (typeof orderProviders)[number]) => boolean) => orderProviders.filter(predicate).map((provider) => provider.id) @@ -111,25 +105,23 @@ export const TradingActionBlock: BlockConfig = { { id: TRADING_PROVIDER_FIELD, title: 'Broker', - type: 'dropdown', + type: 'trading-provider-selector', layout: 'full', - options: [], - fetchOptions: fetchOrderProviderOptions, + tradingProviderKind: 'order', placeholder: 'Select broker', required: true, }, { id: 'portfolioIdentity', title: 'Broker Account', - type: 'dropdown', + type: 'trading-account-selector', layout: 'full', required: true, dependsOn: [TRADING_PROVIDER_FIELD], - enableSearch: true, autoSelectFirstOption: false, placeholder: 'Select broker account', description: 'Broker account used to submit this order.', - fetchOptions: fetchTradingPortfolioIdentityOptions, + tradingProviderFieldId: TRADING_PROVIDER_FIELD, }, { id: 'side', diff --git a/apps/tradinggoose/blocks/blocks/trading_order_contracts.test.ts b/apps/tradinggoose/blocks/blocks/trading_order_contracts.test.ts index 6c94d73f5..53728bf1a 100644 --- a/apps/tradinggoose/blocks/blocks/trading_order_contracts.test.ts +++ b/apps/tradinggoose/blocks/blocks/trading_order_contracts.test.ts @@ -1,5 +1,7 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { evaluateSubBlockConditionValues } from '@/lib/workflows/sub-block-conditions' +import { HistoricalDataBlock } from '@/blocks/blocks/historical_data' +import { TradingPortfolioDetailBlock } from '@/blocks/blocks/portfolio_detail' import { TradingActionBlock } from '@/blocks/blocks/trading_action' import { TradingOrderDetailBlock } from '@/blocks/blocks/trading_order_detail' import { TradingOrderHistoryBlock } from '@/blocks/blocks/trading_order_history' @@ -8,10 +10,6 @@ import { tradingOrderDetailTool } from '@/tools/trading/order_detail' import { orderHistoryTool } from '@/tools/trading/order_history' describe('trading order block contracts', () => { - afterEach(() => { - vi.unstubAllGlobals() - }) - it('exposes workspace scope on order-history tool and block outputs', () => { expect(orderHistoryTool.outputs).toHaveProperty('workspaceId') expect(orderHistoryTool.outputs?.history.items?.properties).toEqual( @@ -75,67 +73,52 @@ describe('trading order block contracts', () => { ]) ).toEqual([ ['provider', true, 'provider', undefined, undefined, undefined], - ['portfolioIdentity', true, 'portfolioIdentity', undefined, undefined, ['provider']], + ['portfolioIdentity', true, 'portfolioIdentity', undefined, 'provider', ['provider']], ['listing', true, 'listing', 'market', 'provider', ['provider']], ]) }) - it('loads enabled broker provider options from OAuth service availability', async () => { + it('uses canonical broker provider and account selector sub-blocks', () => { const provider = TradingActionBlock.subBlocks.find((subBlock) => subBlock.id === 'provider') - const fetchMock = vi.fn(async () => ({ - ok: true, - json: async () => ({ - 'alpaca-paper': true, - 'tradier-live': false, - }), - })) - vi.stubGlobal('fetch', fetchMock) - - const options = await provider?.fetchOptions?.('block-1', 'provider', { - channelId: 'channel-1', - workflowId: 'workflow-1', - }) - - expect(fetchMock).toHaveBeenCalledWith( - '/api/auth/oauth/providers?providers=alpaca-live%2Calpaca-paper%2Ctradier-live', - { - cache: 'no-store', - } - ) - expect(options).toEqual([{ id: 'alpaca', label: 'Alpaca' }]) - }) - - it('loads broker account options for the selected provider', async () => { const portfolioIdentity = TradingActionBlock.subBlocks.find( (subBlock) => subBlock.id === 'portfolioIdentity' ) - const fetchMock = vi.fn(async (url: string) => ({ - ok: true, - json: async () => ({ - options: [ - { - id: url.includes('provider=alpaca') ? 'alpaca-account' : 'tradier-account', - label: url.includes('provider=alpaca') ? 'Alpaca Account' : 'Tradier Account', - }, - ], - }), - })) - vi.stubGlobal('fetch', fetchMock) - const options = await portfolioIdentity?.fetchOptions?.('block-1', 'portfolioIdentity', { - channelId: 'channel-1', - workflowId: 'workflow-1', - contextValues: { provider: 'alpaca' }, + expect(provider).toMatchObject({ + type: 'trading-provider-selector', + tradingProviderKind: 'order', + required: true, + }) + expect(provider?.fetchOptions).toBeUndefined() + expect(portfolioIdentity).toMatchObject({ + type: 'trading-account-selector', + tradingProviderFieldId: 'provider', + dependsOn: ['provider'], + autoSelectFirstOption: false, + required: true, }) + expect(portfolioIdentity?.fetchOptions).toBeUndefined() + }) - expect(fetchMock).toHaveBeenCalledWith( - '/api/providers/trading/portfolio-identities?provider=alpaca', - { - cache: 'no-store', - } - ) - expect(fetchMock).toHaveBeenCalledTimes(1) - expect(options).toEqual([{ id: 'alpaca-account', label: 'Alpaca Account' }]) + it('uses canonical provider selectors on related market and portfolio detail blocks', () => { + expect( + TradingPortfolioDetailBlock.subBlocks.find((subBlock) => subBlock.id === 'provider') + ).toMatchObject({ + type: 'trading-provider-selector', + tradingProviderKind: 'portfolioDetail', + }) + expect( + TradingPortfolioDetailBlock.subBlocks.find((subBlock) => subBlock.id === 'portfolioIdentity') + ).toMatchObject({ + type: 'trading-account-selector', + tradingProviderFieldId: 'provider', + }) + expect( + HistoricalDataBlock.subBlocks.find((subBlock) => subBlock.id === 'provider') + ).toMatchObject({ + type: 'market-provider-selector', + marketProviderKind: 'series', + }) }) it('declares canonical sizing controls directly on the order block', () => { @@ -175,7 +158,7 @@ describe('trading order block contracts', () => { const params = TradingActionBlock.tools.config!.params!({ portfolioIdentity: { providerId: 'tradier', - tokenAccountId: 'oauth-account-1', + credentialId: 'oauth-account-1', serviceId: 'tradier-live', accountId: 'ACC-1', }, diff --git a/apps/tradinggoose/blocks/registry.ts b/apps/tradinggoose/blocks/registry.ts index 71f432a5a..ac71b606f 100644 --- a/apps/tradinggoose/blocks/registry.ts +++ b/apps/tradinggoose/blocks/registry.ts @@ -80,6 +80,7 @@ import { PerplexityBlock } from '@/blocks/blocks/perplexity' import { PineconeBlock } from '@/blocks/blocks/pinecone' import { PipedriveBlock } from '@/blocks/blocks/pipedrive' import { PolymarketBlock } from '@/blocks/blocks/polymarket' +import { TradingPortfolioDetailBlock } from '@/blocks/blocks/portfolio_detail' import { PostgreSQLBlock } from '@/blocks/blocks/postgresql' import { PostHogBlock } from '@/blocks/blocks/posthog' import { QdrantBlock } from '@/blocks/blocks/qdrant' @@ -112,7 +113,6 @@ import { TavilyBlock } from '@/blocks/blocks/tavily' import { TelegramBlock } from '@/blocks/blocks/telegram' import { ThinkingBlock } from '@/blocks/blocks/thinking' import { TradingActionBlock } from '@/blocks/blocks/trading_action' -import { TradingHoldingsBlock } from '@/blocks/blocks/trading_holdings' import { TradingOrderDetailBlock } from '@/blocks/blocks/trading_order_detail' import { TradingOrderHistoryBlock } from '@/blocks/blocks/trading_order_history' import { TranslateBlock } from '@/blocks/blocks/translate' @@ -147,6 +147,7 @@ import { ImapBlock } from '@/triggers/blocks/imap' import { IndicatorTriggerBlock } from '@/triggers/blocks/indicator_trigger' import { InputTriggerBlock } from '@/triggers/blocks/input_trigger' import { ManualTriggerBlock } from '@/triggers/blocks/manual_trigger' +import { PortfolioStateTriggerBlock } from '@/triggers/blocks/portfolio_state_trigger' import { RssBlock } from '@/triggers/blocks/rss' import { ScheduleBlock } from '@/triggers/blocks/schedule' @@ -201,6 +202,7 @@ export const registry: Record = { outlook: OutlookBlock, onedrive: OneDriveBlock, parallel_ai: ParallelBlock, + portfolio_state_trigger: PortfolioStateTriggerBlock, perplexity: PerplexityBlock, pinecone: PineconeBlock, postgresql: PostgreSQLBlock, @@ -228,7 +230,7 @@ export const registry: Record = { telegram: TelegramBlock, thinking: ThinkingBlock, trading_action: TradingActionBlock, - trading_holdings: TradingHoldingsBlock, + portfolio_detail: TradingPortfolioDetailBlock, trading_order_detail: TradingOrderDetailBlock, trading_order_history: TradingOrderHistoryBlock, translate: TranslateBlock, diff --git a/apps/tradinggoose/blocks/types.ts b/apps/tradinggoose/blocks/types.ts index dad6afecb..18d8e8f38 100644 --- a/apps/tradinggoose/blocks/types.ts +++ b/apps/tradinggoose/blocks/types.ts @@ -63,6 +63,9 @@ export type SubBlockType = | 'document-selector' // Document selector for knowledge bases | 'document-tag-entry' // Document tag entry for creating documents | 'market-selector' // Market listing selector (provider/currency/listing) + | 'market-provider-selector' // Market data provider selector + | 'trading-provider-selector' // Trading broker/provider selector + | 'trading-account-selector' // Trading broker account selector | 'order-id-selector' // Trading order selector backed by order history | 'mcp-server-selector' // MCP server selector | 'mcp-tool-selector' // MCP tool selector @@ -154,6 +157,8 @@ export interface SubBlockConfig { mode?: 'basic' | 'advanced' | 'both' | 'trigger' // Default is 'both' if not specified canonicalParamId?: string providerType?: 'market' | 'trading' + marketProviderKind?: 'series' | 'live' + tradingProviderKind?: 'order' | 'portfolioDetail' tradingProviderFieldId?: string required?: boolean | SubBlockCondition | (() => SubBlockCondition) defaultValue?: string | number | boolean | Record | Array @@ -165,7 +170,6 @@ export interface SubBlockConfig { context: BlockOptionLoaderContext ) => Promise fetchOptionsCondition?: SubBlockCondition - optionsStore?: 'marketProviders' min?: number max?: number columns?: string[] diff --git a/apps/tradinggoose/blocks/utils.ts b/apps/tradinggoose/blocks/utils.ts index 4acff5bdf..93ec8cd75 100644 --- a/apps/tradinggoose/blocks/utils.ts +++ b/apps/tradinggoose/blocks/utils.ts @@ -1,18 +1,5 @@ import { isWorkflowParamType } from '@/lib/workflows/value-types' -import type { - BlockOptionLoaderContext, - BlockOutput, - OutputFieldDefinition, - ParamConfig, - ParamType, - SubBlockOption, -} from '@/blocks/types' -import { - getAvailableTradingProviderOptions, - getTradingProviderOAuthServiceIds, - getTradingProvidersByKind, -} from '@/providers/trading/providers' -import type { TradingOperationKind } from '@/providers/trading/types' +import type { BlockOutput, OutputFieldDefinition, ParamConfig, ParamType } from '@/blocks/types' import type { ToolConfig } from '@/tools/types' export function resolveOutputType( @@ -75,53 +62,3 @@ export const buildInputsFromToolParams = ( ]) ) } - -const readContextString = (contextValues: Record | undefined, key: string) => { - const value = contextValues?.[key] - return typeof value === 'string' ? value : '' -} - -export const fetchTradingProviderOptionsByKind = - (kind: TradingOperationKind) => async (): Promise => { - const providers = getTradingProvidersByKind(kind) - const providerIds = Array.from( - new Set(providers.flatMap((provider) => getTradingProviderOAuthServiceIds(provider.id))) - ) - const query = providerIds.length - ? `?providers=${encodeURIComponent(providerIds.join(','))}` - : '' - - const response = await fetch(`/api/auth/oauth/providers${query}`, { - cache: 'no-store', - }) - if (!response.ok) { - throw new Error('Failed to load trading providers') - } - - const availability = (await response.json()) as Record - return getAvailableTradingProviderOptions(availability, kind).map((provider) => ({ - label: provider.name, - id: provider.id, - })) - } - -export const fetchTradingPortfolioIdentityOptions = async ( - _blockId: string, - _subBlockId: string, - context: BlockOptionLoaderContext -): Promise => { - const provider = readContextString(context.contextValues, 'provider') - if (!provider) return [] - - const params = new URLSearchParams({ provider }) - - const response = await fetch(`/api/providers/trading/portfolio-identities?${params}`, { - cache: 'no-store', - }) - if (!response.ok) { - throw new Error('Failed to load trading accounts') - } - - const data = (await response.json()) as { options?: SubBlockOption[] } - return data.options ?? [] -} diff --git a/apps/tradinggoose/components/listing-selector/selector/dropdown.tsx b/apps/tradinggoose/components/listing-selector/selector/dropdown.tsx index b9f6187e7..e0603c63c 100644 --- a/apps/tradinggoose/components/listing-selector/selector/dropdown.tsx +++ b/apps/tradinggoose/components/listing-selector/selector/dropdown.tsx @@ -48,7 +48,7 @@ export function ListingSelectorDropdownContent({ }, [highlightedIndex]) return ( -
+
: undefined - } + renderListing={(listing) => ( + + )} scrollStyle={{ scrollbarWidth: 'thin', overscrollBehavior: 'contain' }} onWheelCapture={(event) => event.stopPropagation()} onTouchMove={(event) => event.stopPropagation()} diff --git a/apps/tradinggoose/widgets/widgets/components/market-provider-controls.tsx b/apps/tradinggoose/components/market-selector/provider-controls.tsx similarity index 82% rename from apps/tradinggoose/widgets/widgets/components/market-provider-controls.tsx rename to apps/tradinggoose/components/market-selector/provider-controls.tsx index 1cdf54fc3..cb4913555 100644 --- a/apps/tradinggoose/widgets/widgets/components/market-provider-controls.tsx +++ b/apps/tradinggoose/components/market-selector/provider-controls.tsx @@ -1,16 +1,14 @@ 'use client' import { useMemo } from 'react' -import { cn } from '@/lib/utils' -import { - type MarketProviderOption, - MarketProviderSelector, -} from '@/widgets/widgets/components/market-provider-selector' +import { MarketProviderSelector } from '@/components/market-selector/provider-selector' import { MarketProviderSettingsButton, type MarketProviderSettingsSaveResult, -} from '@/widgets/widgets/components/market-provider-settings-button' -import { widgetHeaderButtonGroupClassName } from '@/widgets/widgets/components/widget-header-control' +} from '@/components/market-selector/provider-settings-button' +import { widgetHeaderButtonGroupClassName } from '@/components/widget-header-control' +import { cn } from '@/lib/utils' +import type { MarketProviderOption } from '@/providers/market/providers' type MarketProviderControlsProps = { value?: string | null diff --git a/apps/tradinggoose/widgets/widgets/components/market-provider-selector.test.tsx b/apps/tradinggoose/components/market-selector/provider-selector.test.tsx similarity index 69% rename from apps/tradinggoose/widgets/widgets/components/market-provider-selector.test.tsx rename to apps/tradinggoose/components/market-selector/provider-selector.test.tsx index a99f7f8d1..00df3a214 100644 --- a/apps/tradinggoose/widgets/widgets/components/market-provider-selector.test.tsx +++ b/apps/tradinggoose/components/market-selector/provider-selector.test.tsx @@ -5,8 +5,8 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { MarketProviderSelector } from '@/components/market-selector/provider-selector' import { TooltipProvider } from '@/components/ui/tooltip' -import { MarketProviderSelector } from '@/widgets/widgets/components/market-provider-selector' describe('MarketProviderSelector', () => { let container: HTMLDivElement @@ -63,4 +63,24 @@ describe('MarketProviderSelector', () => { const button = container.querySelector('button[aria-label="Select market provider"]') expect(button?.textContent).toContain('Select market data') }) + + it('uses form input styling without the widget market prefix when requested', () => { + act(() => { + root.render( + + + + ) + }) + + const button = container.querySelector('button[aria-label="Select market provider"]') + expect(button?.textContent).toContain('Yahoo Finance') + expect(button?.textContent).not.toContain('Market:') + expect(button?.className).toContain('h-10') + expect(button?.className).toContain('rounded-md') + }) }) diff --git a/apps/tradinggoose/components/market-selector/provider-selector.tsx b/apps/tradinggoose/components/market-selector/provider-selector.tsx new file mode 100644 index 000000000..5a872589e --- /dev/null +++ b/apps/tradinggoose/components/market-selector/provider-selector.tsx @@ -0,0 +1,46 @@ +'use client' + +import { ProviderSelector, type ProviderSelectorVariant } from '@/components/provider-selector' +import type { MarketProviderOption } from '@/providers/market/providers' + +interface MarketProviderSelectorProps { + value?: string | null + options: MarketProviderOption[] + onChange?: (providerId: string) => void + disabled?: boolean + placeholder?: string + triggerClassName?: string + menuClassName?: string + variant?: ProviderSelectorVariant +} + +const DEFAULT_PLACEHOLDER = 'Select Market Provider' + +export function MarketProviderSelector({ + value, + options, + onChange, + disabled = false, + placeholder = DEFAULT_PLACEHOLDER, + triggerClassName, + menuClassName, + variant = 'widget', +}: MarketProviderSelectorProps) { + return ( + + currentVariant === 'form' ? option.name : `Market: ${option.name}` + } + /> + ) +} diff --git a/apps/tradinggoose/widgets/widgets/components/market-provider-settings-button.test.tsx b/apps/tradinggoose/components/market-selector/provider-settings-button.test.tsx similarity index 98% rename from apps/tradinggoose/widgets/widgets/components/market-provider-settings-button.test.tsx rename to apps/tradinggoose/components/market-selector/provider-settings-button.test.tsx index bcdbf46d4..0a4a4e42f 100644 --- a/apps/tradinggoose/widgets/widgets/components/market-provider-settings-button.test.tsx +++ b/apps/tradinggoose/components/market-selector/provider-settings-button.test.tsx @@ -6,7 +6,7 @@ import type { ReactNode } from 'react' import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { MarketProviderSettingsButton } from '@/widgets/widgets/components/market-provider-settings-button' +import { MarketProviderSettingsButton } from '@/components/market-selector/provider-settings-button' vi.mock('@/components/ui/tooltip', () => ({ Tooltip: ({ children }: { children?: ReactNode }) => <>{children}, diff --git a/apps/tradinggoose/widgets/widgets/components/market-provider-settings-button.tsx b/apps/tradinggoose/components/market-selector/provider-settings-button.tsx similarity index 98% rename from apps/tradinggoose/widgets/widgets/components/market-provider-settings-button.tsx rename to apps/tradinggoose/components/market-selector/provider-settings-button.tsx index e0ec7db42..b07768a24 100644 --- a/apps/tradinggoose/widgets/widgets/components/market-provider-settings-button.tsx +++ b/apps/tradinggoose/components/market-selector/provider-settings-button.tsx @@ -16,6 +16,7 @@ import { } from '@/components/ui/select' import { Switch } from '@/components/ui/switch' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { widgetHeaderControlClassName } from '@/components/widget-header-control' import { isMarketProviderCredentialDefinition, resolveMarketProviderSettingsDefinitions, @@ -24,7 +25,6 @@ import { } from '@/lib/market/market-provider-settings' import { cn } from '@/lib/utils' import type { MarketProviderParamDefinition } from '@/providers/market/providers' -import { widgetHeaderControlClassName } from '@/widgets/widgets/components/widget-header-control' export type MarketProviderSettingsSaveResult = { auth?: Record @@ -217,9 +217,7 @@ export function MarketProviderSettingsButton({ - {tooltipText} + + {isDropdownDisabled ? 'Provider selection unavailable' : tooltipText} + {options.length === 0 ? ( -
No providers
+
{emptyText}
) : ( options.map((option) => { const isSelected = option.id === value @@ -108,7 +147,7 @@ export function MarketProviderSelector({ return ( { if (option.id === value) return onChange?.(option.id) @@ -120,7 +159,11 @@ export function MarketProviderSelector({ aria-hidden='true' /> ) : null} - {option.name} + + {option.name} + {isSelected ? : null} ) diff --git a/apps/tradinggoose/widgets/widgets/components/trading-account-selector.test.tsx b/apps/tradinggoose/components/trading-selector/account-selector.test.tsx similarity index 86% rename from apps/tradinggoose/widgets/widgets/components/trading-account-selector.test.tsx rename to apps/tradinggoose/components/trading-selector/account-selector.test.tsx index 1b112801e..e9b3f273d 100644 --- a/apps/tradinggoose/widgets/widgets/components/trading-account-selector.test.tsx +++ b/apps/tradinggoose/components/trading-selector/account-selector.test.tsx @@ -5,9 +5,9 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TradingAccountSelector } from '@/components/trading-selector/account-selector' import { TooltipProvider } from '@/components/ui/tooltip' import type { PortfolioIdentity } from '@/providers/trading/portfolio-identity' -import { TradingAccountSelector } from '@/widgets/widgets/components/trading-account-selector' const mockUsePortfolioIdentities = vi.fn() const mockUseTradingServices = vi.fn() @@ -16,7 +16,7 @@ vi.mock('@/hooks/queries/trading-portfolio', () => ({ usePortfolioIdentities: (...args: unknown[]) => mockUsePortfolioIdentities(...args), })) -vi.mock('@/widgets/widgets/components/trading-services', () => ({ +vi.mock('@/components/trading-selector/services', () => ({ getTradingServiceName: vi.fn(() => 'Primary Broker'), useTradingServices: (...args: unknown[]) => mockUseTradingServices(...args), })) @@ -26,7 +26,7 @@ describe('TradingAccountSelector', () => { let root: Root const selectedPortfolioIdentity: PortfolioIdentity = { providerId: 'alpaca', - tokenAccountId: 'oauth-account-1', + credentialId: 'oauth-credential-1', serviceId: 'alpaca-live', accountId: 'acct-1', } @@ -59,7 +59,7 @@ describe('TradingAccountSelector', () => { }, { providerId: 'alpaca', - tokenAccountId: 'oauth-account-2', + credentialId: 'oauth-credential-1', serviceId: 'alpaca-live', accountId: 'acct-2', accountName: 'Live Account', @@ -164,7 +164,7 @@ describe('TradingAccountSelector', () => { serviceId='alpaca-live' portfolioIdentity={{ providerId: 'alpaca', - tokenAccountId: 'oauth-account-1', + credentialId: 'oauth-credential-1', serviceId: 'alpaca-live', accountId: '8b594a8c-1353-40d0-981c-e022a879e0e0', }} @@ -187,7 +187,7 @@ describe('TradingAccountSelector', () => { serviceId='alpaca-live' portfolioIdentity={{ providerId: 'alpaca', - tokenAccountId: 'oauth-account-1', + credentialId: 'oauth-credential-1', serviceId: 'alpaca-live', accountId: 'stale-account-id', }} @@ -201,4 +201,24 @@ describe('TradingAccountSelector', () => { expect(button?.textContent).toContain('Select account') expect(button?.textContent).not.toContain('stale-account-id') }) + + it('uses form input styling when requested', () => { + act(() => { + root.render( + + + + ) + }) + + const button = container.querySelector('button[aria-label="Select trading account"]') + expect(button?.textContent).toContain('Alpaca Account') + expect(button?.className).toContain('h-10') + expect(button?.className).toContain('rounded-md') + }) }) diff --git a/apps/tradinggoose/widgets/widgets/components/trading-account-selector.tsx b/apps/tradinggoose/components/trading-selector/account-selector.tsx similarity index 84% rename from apps/tradinggoose/widgets/widgets/components/trading-account-selector.tsx rename to apps/tradinggoose/components/trading-selector/account-selector.tsx index fe89879c2..67ea1e54b 100644 --- a/apps/tradinggoose/widgets/widgets/components/trading-account-selector.tsx +++ b/apps/tradinggoose/components/trading-selector/account-selector.tsx @@ -2,6 +2,15 @@ import { useState } from 'react' import { Check, ChevronDown, Plus, RefreshCw } from 'lucide-react' +import { OAuthRequiredModal } from '@/components/oauth/oauth-required-modal' +import { + type ProviderSelectorVariant, + providerSelectorMenuContentClassName, + providerSelectorMenuItemClassName, + providerSelectorTriggerClassName, +} from '@/components/provider-selector' +import { resolveTradingProviderIcon } from '@/components/trading-selector/provider-selector' +import { getTradingServiceName, useTradingServices } from '@/components/trading-selector/services' import { DropdownMenu, DropdownMenuContent, @@ -19,17 +28,6 @@ import { toPortfolioValueObject, } from '@/providers/trading/portfolio-identity' import { getTradingProviderDefinition } from '@/providers/trading/providers' -import { resolveTradingProviderIcon } from '@/widgets/widgets/components/trading-provider-selector' -import { - getTradingServiceName, - useTradingServices, -} from '@/widgets/widgets/components/trading-services' -import { - widgetHeaderControlClassName, - widgetHeaderMenuContentClassName, - widgetHeaderMenuItemClassName, -} from '@/widgets/widgets/components/widget-header-control' -import { OAuthRequiredModal } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal' export type TradingAccountSelection = { serviceId?: string | null @@ -45,6 +43,7 @@ type TradingAccountSelectorProps = { tooltipText?: string toolName?: string onAccountSelect?: (selection: TradingAccountSelection) => void + variant?: ProviderSelectorVariant } const getAccountName = (portfolioIdentity: PortfolioIdentity) => @@ -75,6 +74,7 @@ export function TradingAccountSelector({ tooltipText = 'Select trading account', toolName = 'Trading', onAccountSelect, + variant = 'widget', }: TradingAccountSelectorProps) { const [showOAuthModal, setShowOAuthModal] = useState(false) const [oauthModalServiceId, setOAuthModalServiceId] = useState(null) @@ -130,16 +130,16 @@ export function TradingAccountSelector({ - + @@ -167,7 +169,10 @@ export function TradingAccountSelector({ {services.isLoading ? (
@@ -188,7 +193,10 @@ export function TradingAccountSelector({ {services.connectedServiceIds.map((serviceId) => ( { onAccountSelect?.({ portfolioIdentity: null, serviceId: serviceId }) }} @@ -221,7 +229,10 @@ export function TradingAccountSelector({ return ( { if (isSelected) return onAccountSelect?.({ @@ -230,9 +241,9 @@ export function TradingAccountSelector({ }) }} > - + {getAccountName(account)} - {accountDescription ? ( + {variant === 'widget' && accountDescription ? ( {accountDescription} @@ -250,7 +261,10 @@ export function TradingAccountSelector({ {services.serviceIds.map((serviceId) => ( openOAuthModal(serviceId)} > diff --git a/apps/tradinggoose/widgets/widgets/components/trading-provider-controls.tsx b/apps/tradinggoose/components/trading-selector/provider-controls.tsx similarity index 89% rename from apps/tradinggoose/widgets/widgets/components/trading-provider-controls.tsx rename to apps/tradinggoose/components/trading-selector/provider-controls.tsx index 6faf80cb1..d2d657251 100644 --- a/apps/tradinggoose/widgets/widgets/components/trading-provider-controls.tsx +++ b/apps/tradinggoose/components/trading-selector/provider-controls.tsx @@ -1,16 +1,16 @@ 'use client' -import { cn } from '@/lib/utils' -import type { PortfolioIdentity } from '@/providers/trading/portfolio-identity' import { type TradingAccountSelection, TradingAccountSelector, -} from '@/widgets/widgets/components/trading-account-selector' +} from '@/components/trading-selector/account-selector' import { type TradingProviderOption, TradingProviderSelector, -} from '@/widgets/widgets/components/trading-provider-selector' -import { widgetHeaderButtonGroupClassName } from '@/widgets/widgets/components/widget-header-control' +} from '@/components/trading-selector/provider-selector' +import { widgetHeaderButtonGroupClassName } from '@/components/widget-header-control' +import { cn } from '@/lib/utils' +import type { PortfolioIdentity } from '@/providers/trading/portfolio-identity' type TradingProviderControlsProps = { providerId?: string | null diff --git a/apps/tradinggoose/widgets/widgets/components/trading-provider-selector.test.tsx b/apps/tradinggoose/components/trading-selector/provider-selector.test.tsx similarity index 70% rename from apps/tradinggoose/widgets/widgets/components/trading-provider-selector.test.tsx rename to apps/tradinggoose/components/trading-selector/provider-selector.test.tsx index b8f552b79..b9f32f830 100644 --- a/apps/tradinggoose/widgets/widgets/components/trading-provider-selector.test.tsx +++ b/apps/tradinggoose/components/trading-selector/provider-selector.test.tsx @@ -5,8 +5,8 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { TradingProviderSelector } from '@/components/trading-selector/provider-selector' import { TooltipProvider } from '@/components/ui/tooltip' -import { TradingProviderSelector } from '@/widgets/widgets/components/trading-provider-selector' describe('TradingProviderSelector', () => { let container: HTMLDivElement @@ -63,4 +63,24 @@ describe('TradingProviderSelector', () => { const button = container.querySelector('button[aria-label="Select trading provider"]') expect(button?.textContent).toContain('Select broker') }) + + it('uses form input styling without the widget broker prefix when requested', () => { + act(() => { + root.render( + + + + ) + }) + + const button = container.querySelector('button[aria-label="Select trading provider"]') + expect(button?.textContent).toContain('Alpaca') + expect(button?.textContent).not.toContain('Broker:') + expect(button?.className).toContain('h-10') + expect(button?.className).toContain('rounded-md') + }) }) diff --git a/apps/tradinggoose/components/trading-selector/provider-selector.tsx b/apps/tradinggoose/components/trading-selector/provider-selector.tsx new file mode 100644 index 000000000..8daf74743 --- /dev/null +++ b/apps/tradinggoose/components/trading-selector/provider-selector.tsx @@ -0,0 +1,69 @@ +'use client' + +import { ProviderSelector, type ProviderSelectorVariant } from '@/components/provider-selector' +import { OAUTH_PROVIDERS, parseProvider } from '@/lib/oauth' +import { getTradingProviderDefinition } from '@/providers/trading/providers' + +export type TradingProviderOption = { + id: string + name: string +} + +export const resolveTradingProviderIcon = (providerId?: string) => { + if (!providerId) return undefined + + const providerDefinition = getTradingProviderDefinition(providerId) + if (providerDefinition?.icon) return providerDefinition.icon + + const oauthProvider = providerDefinition?.oauth?.provider + return oauthProvider + ? OAUTH_PROVIDERS[parseProvider(oauthProvider).baseProvider]?.icon + : undefined +} + +type TradingProviderSelectorProps = { + value?: string | null + options: TradingProviderOption[] + onChange?: (providerId: string) => void + disabled?: boolean + placeholder?: string + triggerClassName?: string + menuClassName?: string + variant?: ProviderSelectorVariant +} + +const DEFAULT_PLACEHOLDER = 'Select Trading Provider' + +export function TradingProviderSelector({ + value, + options, + onChange, + disabled = false, + placeholder = DEFAULT_PLACEHOLDER, + triggerClassName, + menuClassName, + variant = 'widget', +}: TradingProviderSelectorProps) { + const optionsWithIcons = options.map((option) => ({ + ...option, + icon: resolveTradingProviderIcon(option.id), + })) + + return ( + + currentVariant === 'form' ? option.name : `Broker: ${option.name}` + } + /> + ) +} diff --git a/apps/tradinggoose/widgets/widgets/components/trading-services.test.ts b/apps/tradinggoose/components/trading-selector/services.test.ts similarity index 92% rename from apps/tradinggoose/widgets/widgets/components/trading-services.test.ts rename to apps/tradinggoose/components/trading-selector/services.test.ts index 594fe433a..2465de8c8 100644 --- a/apps/tradinggoose/widgets/widgets/components/trading-services.test.ts +++ b/apps/tradinggoose/components/trading-selector/services.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { resolveActiveTradingServiceId } from '@/widgets/widgets/components/trading-services' +import { resolveActiveTradingServiceId } from '@/components/trading-selector/services' describe('resolveActiveTradingServiceId', () => { it('keeps a requested service only when it is connected', () => { diff --git a/apps/tradinggoose/widgets/widgets/components/trading-services.ts b/apps/tradinggoose/components/trading-selector/services.ts similarity index 95% rename from apps/tradinggoose/widgets/widgets/components/trading-services.ts rename to apps/tradinggoose/components/trading-selector/services.ts index 5e3618e17..a5a5f9d31 100644 --- a/apps/tradinggoose/widgets/widgets/components/trading-services.ts +++ b/apps/tradinggoose/components/trading-selector/services.ts @@ -62,7 +62,8 @@ export function useTradingServices({ connectedServiceIds, activeServiceId, isLoading: isQueryEnabled ? connectionsQuery.isLoading : false, - error: isQueryEnabled && connectionsQuery.error instanceof Error ? connectionsQuery.error : null, + error: + isQueryEnabled && connectionsQuery.error instanceof Error ? connectionsQuery.error : null, refetch: () => { void connectionsQuery.refetch() }, diff --git a/apps/tradinggoose/components/ui/dropdown-menu.tsx b/apps/tradinggoose/components/ui/dropdown-menu.tsx index 7fa615848..00be8eeab 100644 --- a/apps/tradinggoose/components/ui/dropdown-menu.tsx +++ b/apps/tradinggoose/components/ui/dropdown-menu.tsx @@ -23,6 +23,9 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup +export const dropdownMenuItemClassName = + 'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0' + const DropdownMenuSubTrigger = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { @@ -124,11 +127,7 @@ const DropdownMenuItem = React.forwardRef< >(({ className, inset, ...props }, ref) => ( )) diff --git a/apps/tradinggoose/components/ui/select.tsx b/apps/tradinggoose/components/ui/select.tsx index d37f6b71f..ae0808b43 100644 --- a/apps/tradinggoose/components/ui/select.tsx +++ b/apps/tradinggoose/components/ui/select.tsx @@ -3,6 +3,7 @@ import * as React from 'react' import * as SelectPrimitive from '@radix-ui/react-select' import { Check, ChevronDown, ChevronUp } from 'lucide-react' +import { dropdownMenuItemClassName } from '@/components/ui/dropdown-menu' import { cn } from '@/lib/utils' const Select = SelectPrimitive.Root @@ -11,18 +12,18 @@ const SelectGroup = SelectPrimitive.Group const SelectValue = SelectPrimitive.Value +export function selectTriggerClassName(className?: string) { + return cn( + 'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1', + className + ) +} + const SelectTrigger = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, children, ...props }, ref) => ( - span]:line-clamp-1', - className - )} - {...props} - > + {children} @@ -75,7 +76,7 @@ const SelectContent = React.forwardRef< className={cn( 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in', position === 'popper' && - 'data-[side=left]:-translate-x-1 data-[side=top]:-translate-y-1 data-[side=right]:translate-x-1 data-[side=bottom]:translate-y-1', + 'data-[side=left]:-translate-x-1 data-[side=top]:-translate-y-1 data-[side=right]:translate-x-1 data-[side=bottom]:translate-y-1', className )} position={position} @@ -86,7 +87,7 @@ const SelectContent = React.forwardRef< className={cn( 'scrollbar-thin scrollbar-thumb-slate-200 scrollbar-track-transparent p-1', position === 'popper' && - 'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]' + 'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]' )} > {children} @@ -115,19 +116,15 @@ const SelectItem = React.forwardRef< >(({ className, children, ...props }, ref) => ( - + {children} + - - {children} )) SelectItem.displayName = SelectPrimitive.Item.displayName diff --git a/apps/tradinggoose/widgets/widgets/components/widget-header-control.ts b/apps/tradinggoose/components/widget-header-control.ts similarity index 100% rename from apps/tradinggoose/widgets/widgets/components/widget-header-control.ts rename to apps/tradinggoose/components/widget-header-control.ts diff --git a/apps/tradinggoose/hooks/queries/logs.ts b/apps/tradinggoose/hooks/queries/logs.ts index 6ff547bc8..c176b5a30 100644 --- a/apps/tradinggoose/hooks/queries/logs.ts +++ b/apps/tradinggoose/hooks/queries/logs.ts @@ -45,7 +45,7 @@ export interface LogFilters { indicatorId?: string providerId?: string interval?: string - triggerSource?: 'indicator_trigger' + triggerSource?: string } const resolveLogFilters = ( diff --git a/apps/tradinggoose/hooks/queries/trading-portfolio.ts b/apps/tradinggoose/hooks/queries/trading-portfolio.ts index 08f3641e6..7de53e9cc 100644 --- a/apps/tradinggoose/hooks/queries/trading-portfolio.ts +++ b/apps/tradinggoose/hooks/queries/trading-portfolio.ts @@ -28,6 +28,7 @@ type TradingAccountsRequest = { } type TradingSnapshotRequest = TradingAccountsRequest & { + workspaceId?: string portfolioIdentity?: PortfolioIdentity | null } @@ -36,6 +37,7 @@ type TradingPerformanceRequest = TradingSnapshotRequest & { } type TradingPortfolioSubscribedPayload = { + workspaceId?: string provider?: string serviceId?: string channel?: TradingPortfolioChannel @@ -76,6 +78,7 @@ type TradingSocketQueryResult = { type SocketSubscriptionRef = { subscriptionId?: string clientSubscriptionId: string + workspaceId?: string provider: string serviceId?: string channel: TradingPortfolioChannel @@ -113,6 +116,7 @@ const postJson = async (url: string, body: unknown): Promise => { function useTradingPortfolioSocketData({ channel, provider, + workspaceId, serviceId, portfolioIdentity, window, @@ -123,6 +127,7 @@ function useTradingPortfolioSocketData({ }: { channel: TradingPortfolioChannel provider?: string + workspaceId?: string serviceId?: string portfolioIdentity?: PortfolioIdentity | null window?: TradingPortfolioPerformanceWindow @@ -146,6 +151,7 @@ function useTradingPortfolioSocketData({ const subscriptionRef = useRef(null) const normalizedProvider = provider?.trim() + const normalizedWorkspaceId = workspaceId?.trim() const normalizedServiceId = serviceId?.trim() const normalizedPortfolioIdentity = toPortfolioValueObject(portfolioIdentity) const normalizedPortfolioIdentityKey = normalizedPortfolioIdentity @@ -153,6 +159,7 @@ function useTradingPortfolioSocketData({ : '' const requestKey = [ channel, + normalizedWorkspaceId ?? '', normalizedProvider ?? '', normalizedServiceId ?? '', normalizedPortfolioIdentityKey, @@ -162,6 +169,7 @@ function useTradingPortfolioSocketData({ const shouldSubscribe = enabled && Boolean(normalizedProvider) && + (channel === 'accounts' || Boolean(normalizedWorkspaceId)) && (channel === 'accounts' || Boolean(normalizedPortfolioIdentityKey)) && (channel !== 'portfolio-performance' || Boolean(window)) const isCurrentRequestResolved = dataState.key === requestKey @@ -196,6 +204,7 @@ function useTradingPortfolioSocketData({ subscriptionRef.current = { clientSubscriptionId, + workspaceId: normalizedWorkspaceId, provider: normalizedProvider as string, serviceId: normalizedServiceId, channel, @@ -208,12 +217,9 @@ function useTradingPortfolioSocketData({ const isRelevantPayload = (payload: TradingPortfolioSubscribedPayload) => { if (payload.channel && payload.channel !== channel) return false + if (payload.workspaceId && payload.workspaceId !== normalizedWorkspaceId) return false if (payload.provider && payload.provider !== normalizedProvider) return false - if ( - payload.serviceId && - normalizedServiceId && - payload.serviceId !== normalizedServiceId - ) { + if (payload.serviceId && normalizedServiceId && payload.serviceId !== normalizedServiceId) { return false } const payloadPortfolioIdentity = toPortfolioValueObject(payload.portfolioIdentity) @@ -237,6 +243,7 @@ function useTradingPortfolioSocketData({ const subscribe = (forceRefresh = false) => { socket.emit('trading-portfolio-subscribe', { provider: normalizedProvider, + workspaceId: normalizedWorkspaceId, serviceId: normalizedServiceId, channel, portfolioIdentity: normalizedPortfolioIdentity, @@ -299,6 +306,7 @@ function useTradingPortfolioSocketData({ } else { socket.emit('trading-portfolio-unsubscribe', { provider: current.provider, + workspaceId: current.workspaceId, serviceId: current.serviceId, channel: current.channel, portfolioIdentity: current.portfolioIdentity, @@ -314,6 +322,7 @@ function useTradingPortfolioSocketData({ normalizedServiceId, normalizedPortfolioIdentityKey, normalizedProvider, + normalizedWorkspaceId, refetchNonce, refreshKey, requestKey, @@ -330,6 +339,7 @@ function useTradingPortfolioSocketData({ subscriptionId: current.subscriptionId, clientSubscriptionId: current.clientSubscriptionId, provider: current.provider, + workspaceId: current.workspaceId, serviceId: current.serviceId, channel: current.channel, portfolioIdentity: current.portfolioIdentity, @@ -365,6 +375,7 @@ export function usePortfolioDetail(request: TradingSnapshotRequest) { return useTradingPortfolioSocketData({ channel: 'account-snapshot', provider: request.provider, + workspaceId: request.workspaceId, serviceId: request.serviceId, portfolioIdentity: request.portfolioIdentity, refreshKey: request.refreshKey, @@ -378,6 +389,7 @@ export function usePortfolioPerformance(request: TradingPerformanceRequest) { return useTradingPortfolioSocketData({ channel: 'portfolio-performance', provider: request.provider, + workspaceId: request.workspaceId, serviceId: request.serviceId, portfolioIdentity: request.portfolioIdentity, window: request.selectedWindow, diff --git a/apps/tradinggoose/lib/copilot/monitor/monitor-documents.ts b/apps/tradinggoose/lib/copilot/monitor/monitor-documents.ts index 4c0c590c8..7d8087f4e 100644 --- a/apps/tradinggoose/lib/copilot/monitor/monitor-documents.ts +++ b/apps/tradinggoose/lib/copilot/monitor/monitor-documents.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { INDICATOR_MONITOR_PROVIDER, PORTFOLIO_MONITOR_PROVIDER } from '@/lib/monitors/sources' export const MONITOR_DOCUMENT_FORMAT = 'tg-monitor-document-v1' as const @@ -11,7 +12,8 @@ const MonitorListingSchema = z }) .passthrough() -export const MonitorDocumentSchema = z.object({ +const IndicatorMonitorDocumentSchema = z.object({ + source: z.literal(INDICATOR_MONITOR_PROVIDER), workflowId: z.string(), blockId: z.string(), providerId: z.string(), @@ -27,12 +29,64 @@ export const MonitorDocumentSchema = z.object({ .optional(), }) +const PortfolioMonitorDocumentSchema = z.object({ + source: z.literal(PORTFOLIO_MONITOR_PROVIDER), + workflowId: z.string(), + blockId: z.string(), + providerId: z.string(), + serviceId: z.string(), + credentialId: z.string(), + accountId: z.string(), + condition: z.unknown(), + fireMode: z.enum(['edge', 'while_true']), + cooldownSeconds: z.number().int().min(0), + pollIntervalSeconds: z.number().int().min(15), + isActive: z.boolean(), +}) + +export const MonitorDocumentSchema = z.discriminatedUnion('source', [ + IndicatorMonitorDocumentSchema, + PortfolioMonitorDocumentSchema, +]) + export type MonitorDocumentFields = z.infer +type IndicatorMonitorDocumentFields = z.infer function normalizeRecord( record: Record | null | undefined ): MonitorDocumentFields { const source = record ?? {} + const monitorSource = source.source + if ( + monitorSource !== INDICATOR_MONITOR_PROVIDER && + monitorSource !== PORTFOLIO_MONITOR_PROVIDER + ) { + throw new Error('Monitor document source is required.') + } + if (monitorSource === PORTFOLIO_MONITOR_PROVIDER) { + return { + source: PORTFOLIO_MONITOR_PROVIDER, + workflowId: typeof source.workflowId === 'string' ? source.workflowId : '', + blockId: typeof source.blockId === 'string' ? source.blockId : '', + providerId: typeof source.providerId === 'string' ? source.providerId : '', + serviceId: typeof source.serviceId === 'string' ? source.serviceId : '', + credentialId: typeof source.credentialId === 'string' ? source.credentialId : '', + accountId: typeof source.accountId === 'string' ? source.accountId : '', + condition: source.condition ?? null, + fireMode: source.fireMode === 'while_true' ? 'while_true' : 'edge', + cooldownSeconds: + typeof source.cooldownSeconds === 'number' && Number.isFinite(source.cooldownSeconds) + ? source.cooldownSeconds + : 300, + pollIntervalSeconds: + typeof source.pollIntervalSeconds === 'number' && + Number.isFinite(source.pollIntervalSeconds) + ? source.pollIntervalSeconds + : 60, + isActive: typeof source.isActive === 'boolean' ? source.isActive : true, + } + } + const listingSource = source.listing && typeof source.listing === 'object' && !Array.isArray(source.listing) ? (source.listing as Record) @@ -43,6 +97,7 @@ function normalizeRecord( : null return { + source: INDICATOR_MONITOR_PROVIDER, workflowId: typeof source.workflowId === 'string' ? source.workflowId : '', blockId: typeof source.blockId === 'string' ? source.blockId : '', providerId: typeof source.providerId === 'string' ? source.providerId : '', @@ -66,7 +121,7 @@ function normalizeRecord( !Array.isArray(source.providerParams) ? { providerParams: source.providerParams as Record } : {}), - ...(authSource && authSource.secrets && typeof authSource.secrets === 'object' + ...(authSource?.secrets && typeof authSource.secrets === 'object' ? { auth: { secrets: Object.fromEntries( @@ -93,7 +148,7 @@ export function serializeMonitorDocument( return JSON.stringify(parsed, null, 2) } -function getListingLabel(listing: MonitorDocumentFields['listing']): string { +function getListingLabel(listing: IndicatorMonitorDocumentFields['listing']): string { const anyListing = listing as Record const name = typeof anyListing.name === 'string' ? anyListing.name.trim() : '' if (name) return name @@ -111,6 +166,10 @@ export function readMonitorDocumentName( fields: Record | null | undefined ): string { const parsed = normalizeRecord(fields) + if (parsed.source === PORTFOLIO_MONITOR_PROVIDER) { + return `Portfolio state (${parsed.accountId || 'account'})` + } + const listingLabel = getListingLabel(parsed.listing) const indicatorLabel = parsed.indicatorId || 'indicator' const intervalLabel = parsed.interval || 'interval' diff --git a/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts b/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts index fac9db780..a1dcbb4ab 100644 --- a/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts +++ b/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts @@ -197,7 +197,7 @@ export const TOOL_PROMPT_METADATA: Record = { }, list_monitors: { description: - 'List indicator monitors in the current workspace, optionally filtered by workflow or block.', + 'List monitors in the current workspace, optionally filtered by workflow or block.', kind: 'list', surfaceKind: 'monitor', }, diff --git a/apps/tradinggoose/lib/copilot/tools/client/monitor/edit-monitor.ts b/apps/tradinggoose/lib/copilot/tools/client/monitor/edit-monitor.ts index ed4e00711..52cad8440 100644 --- a/apps/tradinggoose/lib/copilot/tools/client/monitor/edit-monitor.ts +++ b/apps/tradinggoose/lib/copilot/tools/client/monitor/edit-monitor.ts @@ -13,7 +13,7 @@ import { import { resolveWorkspaceIdFromExecutionContext } from '@/lib/copilot/tools/client/entities/entity-document-tool-utils' import { type EditMonitorArgs, - type IndicatorMonitorRecord, + type MonitorRecord, readStoredToolArgs, toMonitorDocumentFields, } from '@/lib/copilot/tools/client/monitor/monitor-tool-utils' @@ -74,27 +74,37 @@ export class EditMonitorClientTool extends BaseClientTool { const workspaceId = resolveWorkspaceIdFromExecutionContext(executionContext) const nextFields = parseMonitorDocument(resolvedArgs.monitorDocument) - const response = await fetch( - `/api/indicator-monitors/${encodeURIComponent(resolvedArgs.monitorId)}`, - { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - workspaceId, - workflowId: nextFields.workflowId, - blockId: nextFields.blockId, - providerId: nextFields.providerId, - interval: nextFields.interval, - indicatorId: nextFields.indicatorId, - listing: nextFields.listing, - isActive: nextFields.isActive, - ...(nextFields.providerParams ? { providerParams: nextFields.providerParams } : {}), - ...(nextFields.auth ? { auth: nextFields.auth } : {}), - }), - } - ) + const response = await fetch(`/api/monitors/${encodeURIComponent(resolvedArgs.monitorId)}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + source: nextFields.source, + workspaceId, + workflowId: nextFields.workflowId, + blockId: nextFields.blockId, + providerId: nextFields.providerId, + ...(nextFields.source === 'portfolio' + ? { + serviceId: nextFields.serviceId, + credentialId: nextFields.credentialId, + accountId: nextFields.accountId, + condition: nextFields.condition, + fireMode: nextFields.fireMode, + cooldownSeconds: nextFields.cooldownSeconds, + pollIntervalSeconds: nextFields.pollIntervalSeconds, + } + : { + interval: nextFields.interval, + indicatorId: nextFields.indicatorId, + listing: nextFields.listing, + ...(nextFields.providerParams ? { providerParams: nextFields.providerParams } : {}), + ...(nextFields.auth ? { auth: nextFields.auth } : {}), + }), + isActive: nextFields.isActive, + }), + }) const payload = await response.json().catch(() => ({})) if (!response.ok) { @@ -102,9 +112,7 @@ export class EditMonitorClientTool extends BaseClientTool { } const updatedMonitor = - payload?.data && typeof payload.data === 'object' - ? (payload.data as IndicatorMonitorRecord) - : null + payload?.data && typeof payload.data === 'object' ? (payload.data as MonitorRecord) : null if (!updatedMonitor) { throw new Error('Invalid updated monitor response') diff --git a/apps/tradinggoose/lib/copilot/tools/client/monitor/list-monitors.ts b/apps/tradinggoose/lib/copilot/tools/client/monitor/list-monitors.ts index fce458ecc..fd944eaed 100644 --- a/apps/tradinggoose/lib/copilot/tools/client/monitor/list-monitors.ts +++ b/apps/tradinggoose/lib/copilot/tools/client/monitor/list-monitors.ts @@ -7,8 +7,8 @@ import { import { resolveWorkspaceIdFromExecutionContext } from '@/lib/copilot/tools/client/entities/entity-document-tool-utils' import { buildMonitorName, - type IndicatorMonitorRecord, type ListMonitorArgs, + type MonitorRecord, } from '@/lib/copilot/tools/client/monitor/monitor-tool-utils' export class ListMonitorsClientTool extends BaseClientTool { @@ -44,20 +44,21 @@ export class ListMonitorsClientTool extends BaseClientTool { searchParams.set('blockId', args.blockId) } - const response = await fetch(`/api/indicator-monitors?${searchParams.toString()}`) + const response = await fetch(`/api/monitors?${searchParams.toString()}`) const payload = await response.json().catch(() => ({})) if (!response.ok) { throw new Error(payload?.error || `Failed to fetch monitors: ${response.status}`) } - const monitors = Array.isArray(payload?.data) ? (payload.data as IndicatorMonitorRecord[]) : [] + const monitors = Array.isArray(payload?.data) ? (payload.data as MonitorRecord[]) : [] const monitorsList = monitors.map((monitor) => ({ monitorId: monitor.monitorId, monitorName: buildMonitorName(monitor), monitorDescription: `Workflow ${monitor.workflowId}, block ${monitor.blockId}`, workflowId: monitor.workflowId, blockId: monitor.blockId, + source: monitor.source, providerId: monitor.providerConfig.monitor.providerId, indicatorId: monitor.providerConfig.monitor.indicatorId, interval: monitor.providerConfig.monitor.interval, diff --git a/apps/tradinggoose/lib/copilot/tools/client/monitor/monitor-tool-utils.ts b/apps/tradinggoose/lib/copilot/tools/client/monitor/monitor-tool-utils.ts index d093667c3..8bfb24252 100644 --- a/apps/tradinggoose/lib/copilot/tools/client/monitor/monitor-tool-utils.ts +++ b/apps/tradinggoose/lib/copilot/tools/client/monitor/monitor-tool-utils.ts @@ -1,3 +1,8 @@ +import { + INDICATOR_MONITOR_PROVIDER, + type MonitorWebhookProvider, + PORTFOLIO_MONITOR_PROVIDER, +} from '@/lib/monitors/sources' import { getCopilotStoreForToolCall } from '@/stores/copilot/store-access' export type ListMonitorArgs = { @@ -14,17 +19,25 @@ export type EditMonitorArgs = ReadMonitorArgs & { documentFormat?: string } -export type IndicatorMonitorRecord = { +export type MonitorRecord = { monitorId: string + source: MonitorWebhookProvider workflowId: string blockId: string isActive: boolean providerConfig: { monitor: { providerId: string - interval: string - listing: Record - indicatorId: string + interval?: string + listing?: Record + indicatorId?: string + serviceId?: string + credentialId?: string + accountId?: string + condition?: unknown + fireMode?: 'edge' | 'while_true' + cooldownSeconds?: number + pollIntervalSeconds?: number auth?: { hasEncryptedSecrets?: boolean encryptedSecretFieldIds?: string[] @@ -64,30 +77,51 @@ function getListingLabel(listing: Record | null | undefined): s return baseId && quoteId ? `${baseId}/${quoteId}` : baseId || quoteId || 'listing' } -export function buildMonitorName(record: IndicatorMonitorRecord): string { +export function buildMonitorName(record: MonitorRecord): string { + if (record.source === PORTFOLIO_MONITOR_PROVIDER) { + return `Portfolio state (${record.providerConfig.monitor.accountId || 'account'})` + } + const indicatorId = record.providerConfig.monitor.indicatorId || 'indicator' const interval = record.providerConfig.monitor.interval || 'interval' const listingLabel = getListingLabel(record.providerConfig.monitor.listing) return `${indicatorId} on ${listingLabel} (${interval})` } -export function toMonitorDocumentFields(record: IndicatorMonitorRecord) { +export function toMonitorDocumentFields(record: MonitorRecord) { + const monitor = record.providerConfig.monitor + if (record.source === PORTFOLIO_MONITOR_PROVIDER) { + return { + source: PORTFOLIO_MONITOR_PROVIDER, + workflowId: record.workflowId, + blockId: record.blockId, + providerId: monitor.providerId, + serviceId: monitor.serviceId, + credentialId: monitor.credentialId, + accountId: monitor.accountId, + condition: monitor.condition, + fireMode: monitor.fireMode, + cooldownSeconds: monitor.cooldownSeconds, + pollIntervalSeconds: monitor.pollIntervalSeconds, + isActive: record.isActive, + } + } + return { + source: INDICATOR_MONITOR_PROVIDER, workflowId: record.workflowId, blockId: record.blockId, - providerId: record.providerConfig.monitor.providerId, - interval: record.providerConfig.monitor.interval, - indicatorId: record.providerConfig.monitor.indicatorId, - listing: record.providerConfig.monitor.listing, + providerId: monitor.providerId, + interval: monitor.interval, + indicatorId: monitor.indicatorId, + listing: monitor.listing, isActive: record.isActive, - ...(record.providerConfig.monitor.providerParams - ? { providerParams: record.providerConfig.monitor.providerParams } - : {}), + ...(monitor.providerParams ? { providerParams: monitor.providerParams } : {}), } } -export async function fetchMonitorById(monitorId: string): Promise { - const response = await fetch(`/api/indicator-monitors/${encodeURIComponent(monitorId)}`) +export async function fetchMonitorById(monitorId: string): Promise { + const response = await fetch(`/api/monitors/${encodeURIComponent(monitorId)}`) const payload = await response.json().catch(() => ({})) if (!response.ok) { @@ -98,5 +132,5 @@ export async function fetchMonitorById(monitorId: string): Promise { const url = typeof input === 'string' ? input : input.toString() const method = init?.method || 'GET' - if (url === '/api/indicator-monitors?workspaceId=ws-1' && method === 'GET') { + if (url === '/api/monitors?workspaceId=ws-1' && method === 'GET') { return { ok: true, status: 200, @@ -46,10 +46,13 @@ describe('monitor tools', () => { data: [ { monitorId: 'monitor-1', + source: 'indicator', workflowId: 'wf-1', blockId: 'trigger-1', isActive: true, providerConfig: { + triggerId: 'indicator_trigger', + version: 1, monitor: { providerId: 'alpaca', interval: '1m', @@ -95,7 +98,7 @@ describe('monitor tools', () => { await tool.execute() expect(tool.getState()).toBe(ClientToolCallState.success) - expect(fetchMock).toHaveBeenCalledWith('/api/indicator-monitors?workspaceId=ws-1') + expect(fetchMock).toHaveBeenCalledWith('/api/monitors?workspaceId=ws-1') const markCompleteCall = fetchMock.mock.calls.find(([input, init]) => { const url = typeof input === 'string' ? input : input.toString() @@ -123,17 +126,20 @@ describe('monitor tools', () => { const url = typeof input === 'string' ? input : input.toString() const method = init?.method || 'GET' - if (url === '/api/indicator-monitors/monitor-1' && method === 'GET') { + if (url === '/api/monitors/monitor-1' && method === 'GET') { return { ok: true, status: 200, json: async () => ({ data: { monitorId: 'monitor-1', + source: 'indicator', workflowId: 'wf-1', blockId: 'trigger-1', isActive: true, providerConfig: { + triggerId: 'indicator_trigger', + version: 1, monitor: { providerId: 'alpaca', interval: '5m', @@ -205,9 +211,10 @@ describe('monitor tools', () => { const url = typeof input === 'string' ? input : input.toString() const method = init?.method || 'GET' - if (url === '/api/indicator-monitors/monitor-1' && method === 'PATCH') { + if (url === '/api/monitors/monitor-1' && method === 'PATCH') { const payload = JSON.parse(String(init?.body)) expect(payload).toMatchObject({ + source: 'indicator', workspaceId: 'ws-1', workflowId: 'wf-1', blockId: 'trigger-1', @@ -223,10 +230,13 @@ describe('monitor tools', () => { json: async () => ({ data: { monitorId: 'monitor-1', + source: 'indicator', workflowId: 'wf-1', blockId: 'trigger-1', isActive: false, providerConfig: { + triggerId: 'indicator_trigger', + version: 1, monitor: { providerId: 'alpaca', interval: '15m', @@ -272,6 +282,7 @@ describe('monitor tools', () => { const monitorDocument = JSON.stringify( { + source: 'indicator', workflowId: 'wf-1', blockId: 'trigger-1', providerId: 'alpaca', @@ -327,7 +338,7 @@ describe('monitor tools', () => { ToolArgSchemas.edit_monitor.parse({ monitorId: 'monitor-1', monitorDocument: - '{"workflowId":"wf-1","blockId":"trigger-1","providerId":"alpaca","interval":"1m","indicatorId":"rsi","listing":{"listing_type":"default","listing_id":"AAPL","base_id":"","quote_id":""},"isActive":true}', + '{"source":"indicator","workflowId":"wf-1","blockId":"trigger-1","providerId":"alpaca","interval":"1m","indicatorId":"rsi","listing":{"listing_type":"default","listing_id":"AAPL","base_id":"","quote_id":""},"isActive":true}', }) ).toMatchObject({ monitorId: 'monitor-1', @@ -340,7 +351,7 @@ describe('monitor tools', () => { monitorName: 'rsi on AAPL (1m)', documentFormat: 'tg-monitor-document-v1', monitorDocument: - '{"workflowId":"wf-1","blockId":"trigger-1","providerId":"alpaca","interval":"1m","indicatorId":"rsi","listing":{"listing_type":"default","listing_id":"AAPL","base_id":"","quote_id":""},"isActive":true}', + '{"source":"indicator","workflowId":"wf-1","blockId":"trigger-1","providerId":"alpaca","interval":"1m","indicatorId":"rsi","listing":{"listing_type":"default","listing_id":"AAPL","base_id":"","quote_id":""},"isActive":true}', }) ).toMatchObject({ surfaceKind: 'monitor', diff --git a/apps/tradinggoose/lib/execution/pending-execution.ts b/apps/tradinggoose/lib/execution/pending-execution.ts index 79210b133..8ddea8947 100644 --- a/apps/tradinggoose/lib/execution/pending-execution.ts +++ b/apps/tradinggoose/lib/execution/pending-execution.ts @@ -22,12 +22,7 @@ const STALE_PROCESSING_WINDOW_MS = 30 * 60 * 1000 export const PENDING_EXECUTION_LOCK_NAMESPACE = 29_401 const WORKFLOW_BLOCK_SOURCE = 'workflow_block' -export type PendingExecutionType = - | 'workflow' - | 'webhook' - | 'schedule' - | 'indicator_monitor' - | 'document' +export type PendingExecutionType = 'workflow' | 'webhook' | 'schedule' | 'monitor' | 'document' export type PendingExecutionPayload = Record diff --git a/apps/tradinggoose/lib/indicators/dispatch.ts b/apps/tradinggoose/lib/indicators/dispatch.ts index 445a34c7d..9043ebbaf 100644 --- a/apps/tradinggoose/lib/indicators/dispatch.ts +++ b/apps/tradinggoose/lib/indicators/dispatch.ts @@ -6,6 +6,7 @@ import type { NormalizedPineSignal, } from '@/lib/indicators/types' import type { ListingIdentity } from '@/lib/listing/identity' +import { INDICATOR_MONITOR_PROVIDER, INDICATOR_MONITOR_TRIGGER_ID } from '@/lib/monitors/sources' import type { MarketSeries } from '@/providers/market/types' const DAY_MS = 24 * 60 * 60 * 1000 @@ -88,8 +89,8 @@ export type IndicatorTriggerDispatchPayload = { indicatorId: string } trigger: { - provider: 'indicator' - source: 'indicator_trigger' + provider: typeof INDICATOR_MONITOR_PROVIDER + source: typeof INDICATOR_MONITOR_TRIGGER_ID executionId: string emittedAt: string } @@ -445,8 +446,8 @@ export const buildIndicatorTriggerDispatchPayload = ({ }, monitor, trigger: { - provider: 'indicator', - source: 'indicator_trigger', + provider: INDICATOR_MONITOR_PROVIDER, + source: INDICATOR_MONITOR_TRIGGER_ID, executionId, emittedAt, }, diff --git a/apps/tradinggoose/lib/indicators/monitor-config.test.ts b/apps/tradinggoose/lib/indicators/monitor-config.test.ts index 236c6469b..8577a8861 100644 --- a/apps/tradinggoose/lib/indicators/monitor-config.test.ts +++ b/apps/tradinggoose/lib/indicators/monitor-config.test.ts @@ -78,7 +78,7 @@ describe('normalizeIndicatorMonitorConfig', () => { providerParams: { feed: 'iex' }, } - it('preserves existing partial auth without rechecking newly required secrets', async () => { + it('requires complete auth even when preserving existing secrets', async () => { await expect( normalizeIndicatorMonitorConfig({ ...baseInput, @@ -87,16 +87,7 @@ describe('normalizeIndicatorMonitorConfig', () => { secretVersion: 1, }, }) - ).resolves.toEqual( - expect.objectContaining({ - monitor: expect.objectContaining({ - auth: { - encryptedSecrets: { apiKey: 'encrypted-api-key' }, - secretVersion: 1, - }, - }), - }) - ) + ).rejects.toThrow('Missing required auth secret values for provider fields: apiSecret') }) it('replaces stored auth when explicit auth is provided', async () => { @@ -161,4 +152,16 @@ describe('normalizeIndicatorMonitorConfig', () => { 'Missing required auth secret values for provider fields: apiKey, apiSecret' ) }) + + it('allows polling-backed market providers through the same monitor config path', async () => { + const result = await normalizeIndicatorMonitorConfig({ + ...baseInput, + providerId: 'yahoo-finance', + interval: '1m', + providerParams: {}, + }) + + expect(result.monitor.providerId).toBe('yahoo-finance') + expect(result.monitor.interval).toBe('1m') + }) }) diff --git a/apps/tradinggoose/lib/indicators/monitor-config.ts b/apps/tradinggoose/lib/indicators/monitor-config.ts index 287a21553..bdc0d842f 100644 --- a/apps/tradinggoose/lib/indicators/monitor-config.ts +++ b/apps/tradinggoose/lib/indicators/monitor-config.ts @@ -2,16 +2,14 @@ import { z } from 'zod' import type { InputMeta, InputMetaMap } from '@/lib/indicators/types' import type { ListingIdentity, ListingInputValue } from '@/lib/listing/identity' import { toListingValueObject } from '@/lib/listing/identity' +import { INDICATOR_MONITOR_PROVIDER, INDICATOR_MONITOR_TRIGGER_ID } from '@/lib/monitors/sources' import { encryptSecret } from '@/lib/utils-server' import { coerceMarketProviderParamValue, - getMarketLiveCapabilities, - getMarketProviderParamDefinitions, - getMarketSeriesCapabilities, + getMarketMonitorProviderParamDefinitions, + getMarketProviderIntervals, } from '@/providers/market/providers' -export const INDICATOR_MONITOR_TRIGGER_ID = 'indicator_trigger' as const - const MonitorAuthCreateInputSchema = z.object({ secrets: z.record(z.string()), }) @@ -26,6 +24,7 @@ const ProviderParamsInputSchema = z.record(z.unknown()).optional() const IndicatorInputsInputSchema = z.record(z.unknown()).optional() export const IndicatorMonitorCreateSchema = z.object({ + source: z.literal(INDICATOR_MONITOR_PROVIDER), workspaceId: z.string().min(1), workflowId: z.string().min(1), blockId: z.string().min(1), @@ -40,6 +39,7 @@ export const IndicatorMonitorCreateSchema = z.object({ }) export const IndicatorMonitorUpdateSchema = z.object({ + source: z.literal(INDICATOR_MONITOR_PROVIDER).optional(), workspaceId: z.string().min(1), workflowId: z.string().min(1).optional(), blockId: z.string().min(1).optional(), @@ -78,8 +78,8 @@ export type IndicatorMonitorProviderConfig = { } } -const getRequiredLiveSecretParamIds = (providerId: string): string[] => - getMarketProviderParamDefinitions(providerId, 'live') +const getRequiredMonitorSecretParamIds = (providerId: string): string[] => + getMarketMonitorProviderParamDefinitions(providerId) .filter((definition) => definition.password && definition.required) .map((definition) => definition.id) @@ -87,7 +87,7 @@ const normalizeProviderParams = ( providerId: string, raw: Record | undefined ): Record | undefined => { - const definitions = getMarketProviderParamDefinitions(providerId, 'live') + const definitions = getMarketMonitorProviderParamDefinitions(providerId) const nonSecretDefinitions = definitions.filter((definition) => !definition.password) const definitionMap = new Map( nonSecretDefinitions.map((definition) => [definition.id, definition]) @@ -222,12 +222,7 @@ type NormalizeMonitorConfigInput = { export const normalizeIndicatorMonitorConfig = async ( input: NormalizeMonitorConfigInput ): Promise => { - const liveCapabilities = getMarketLiveCapabilities(input.providerId) - if (!liveCapabilities?.supportsStreaming) { - throw new Error(`Provider ${input.providerId} does not support live streaming.`) - } - - const intervalOptions = getMarketSeriesCapabilities(input.providerId)?.intervals ?? [] + const intervalOptions = getMarketProviderIntervals(input.providerId) if (!intervalOptions.includes(input.interval as any)) { throw new Error(`Interval ${input.interval} is not supported for provider ${input.providerId}.`) } @@ -237,7 +232,7 @@ export const normalizeIndicatorMonitorConfig = async ( throw new Error('Invalid listing value.') } - const requiredSecretParamIds = getRequiredLiveSecretParamIds(input.providerId) + const requiredSecretParamIds = getRequiredMonitorSecretParamIds(input.providerId) const replacingAuth = input.authInput !== undefined const incomingSecretValues = input.authInput?.secrets ?? {} const encryptedSecrets: Record = replacingAuth @@ -254,9 +249,7 @@ export const normalizeIndicatorMonitorConfig = async ( const missingRequiredSecrets = requiredSecretParamIds.filter( (fieldId) => !encryptedSecrets[fieldId] ) - const preservesPreviousAuth = !replacingAuth && Boolean(input.previousAuth) - const shouldRequireCompleteAuth = !preservesPreviousAuth && (input.requireCompleteAuth ?? true) - if (shouldRequireCompleteAuth && missingRequiredSecrets.length > 0) { + if ((input.requireCompleteAuth ?? true) && missingRequiredSecrets.length > 0) { throw new Error( `Missing required auth secret values for provider fields: ${missingRequiredSecrets.join(', ')}` ) diff --git a/apps/tradinggoose/lib/monitors/portfolio-conditions.ts b/apps/tradinggoose/lib/monitors/portfolio-conditions.ts new file mode 100644 index 000000000..09157a8e4 --- /dev/null +++ b/apps/tradinggoose/lib/monitors/portfolio-conditions.ts @@ -0,0 +1,215 @@ +import { + areListingIdentitiesEqual, + type ListingIdentity, + toListingValueObject, +} from '@/lib/listing/identity' +import type { PortfolioDetail } from '@/providers/trading/portfolio-identity' + +export type PortfolioConditionSnapshot = Pick + +export const PORTFOLIO_CONDITION_METRICS = [ + 'summary.totalPortfolioValue', + 'summary.totalCashValue', + 'summary.totalHoldingsValue', + 'summary.totalUnrealizedPnl', + 'summary.buyingPower', + 'summary.equity', + 'positions.count', + 'positions.totalMarketValue', + 'positions.totalUnrealizedPnl', + 'position.quantity', + 'position.marketValue', + 'position.unrealizedPnl', + 'position.unrealizedPnlPercent', + 'position.exists', +] as const + +export const PORTFOLIO_CONDITION_OPERATORS = [ + 'gt', + 'gte', + 'lt', + 'lte', + 'eq', + 'neq', + 'crosses_above', + 'crosses_below', + 'changes_since_previous_by_abs', + 'changes_since_previous_by_percent', + 'exists', + 'not_exists', +] as const + +export type PortfolioConditionMetric = (typeof PORTFOLIO_CONDITION_METRICS)[number] +export type PortfolioConditionOperator = (typeof PORTFOLIO_CONDITION_OPERATORS)[number] + +export type PortfolioConditionRule = { + id?: string + metric: PortfolioConditionMetric + operator: PortfolioConditionOperator + value?: number | string | boolean | null + listing?: ListingIdentity | null +} + +export type PortfolioConditionGroup = { + id?: string + combinator: 'and' | 'or' + rules: PortfolioConditionNode[] +} + +export type PortfolioConditionNode = PortfolioConditionRule | PortfolioConditionGroup + +export type PortfolioFireCondition = { + root: PortfolioConditionGroup +} + +type EvaluationContext = { + current: PortfolioConditionSnapshot + previous?: PortfolioConditionSnapshot | null +} + +const isGroup = (node: PortfolioConditionNode): node is PortfolioConditionGroup => + Array.isArray((node as PortfolioConditionGroup).rules) + +const toFiniteNumber = (value: unknown): number | null => + typeof value === 'number' && Number.isFinite(value) ? value : null + +const toTargetNumber = (value: unknown): number | null => { + if (typeof value === 'number' && Number.isFinite(value)) return value + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : null + } + return null +} + +export const portfolioConditionRequiresListing = (metric: PortfolioConditionMetric) => + metric.startsWith('position.') + +export const isPortfolioConditionValuelessOperator = (operator: PortfolioConditionOperator) => + operator === 'exists' || operator === 'not_exists' + +export const isPortfolioConditionOperatorCompatible = ( + metric: PortfolioConditionMetric, + operator: PortfolioConditionOperator +) => isPortfolioConditionValuelessOperator(operator) === (metric === 'position.exists') + +export const getPortfolioConditionOperatorsForMetric = (metric: PortfolioConditionMetric) => + PORTFOLIO_CONDITION_OPERATORS.filter((operator) => + isPortfolioConditionOperatorCompatible(metric, operator) + ) + +const findPosition = (portfolio: PortfolioConditionSnapshot, listingInput: unknown) => { + const listing = toListingValueObject(listingInput) + if (!listing) return null + return ( + portfolio.positions.find((position) => { + const positionListing = toListingValueObject(position.listingIdentity) + return areListingIdentitiesEqual(positionListing, listing) + }) ?? null + ) +} + +const getMetricValue = ( + portfolio: PortfolioConditionSnapshot | null | undefined, + rule: PortfolioConditionRule +): number | boolean | null => { + if (!portfolio) return null + + switch (rule.metric) { + case 'summary.totalPortfolioValue': + return toFiniteNumber(portfolio.summary.totalPortfolioValue) + case 'summary.totalCashValue': + return toFiniteNumber(portfolio.summary.totalCashValue) + case 'summary.totalHoldingsValue': + return toFiniteNumber(portfolio.summary.totalHoldingsValue) + case 'summary.totalUnrealizedPnl': + return toFiniteNumber(portfolio.summary.totalUnrealizedPnl) + case 'summary.buyingPower': + return toFiniteNumber(portfolio.summary.buyingPower) + case 'summary.equity': + return toFiniteNumber(portfolio.summary.equity) + case 'positions.count': + return portfolio.positions.length + case 'positions.totalMarketValue': + return portfolio.positions.reduce((sum, position) => sum + (position.marketValue ?? 0), 0) + case 'positions.totalUnrealizedPnl': + return portfolio.positions.reduce((sum, position) => sum + (position.unrealizedPnl ?? 0), 0) + case 'position.quantity': + return toFiniteNumber(findPosition(portfolio, rule.listing)?.quantity) + case 'position.marketValue': + return toFiniteNumber(findPosition(portfolio, rule.listing)?.marketValue) + case 'position.unrealizedPnl': + return toFiniteNumber(findPosition(portfolio, rule.listing)?.unrealizedPnl) + case 'position.unrealizedPnlPercent': + return toFiniteNumber(findPosition(portfolio, rule.listing)?.unrealizedPnlPercent) + case 'position.exists': + return Boolean(findPosition(portfolio, rule.listing)) + } +} + +const evaluateRule = (rule: PortfolioConditionRule, context: EvaluationContext) => { + if (!isPortfolioConditionOperatorCompatible(rule.metric, rule.operator)) return false + if (portfolioConditionRequiresListing(rule.metric) && !toListingValueObject(rule.listing)) { + return false + } + + const currentValue = getMetricValue(context.current, rule) + const previousValue = getMetricValue(context.previous, rule) + + if (rule.operator === 'exists') return currentValue === true + if (rule.operator === 'not_exists') return currentValue === false + + const currentNumber = toFiniteNumber(currentValue) + const previousNumber = toFiniteNumber(previousValue) + const target = toTargetNumber(rule.value) + + if (currentNumber === null || target === null) return false + + switch (rule.operator) { + case 'gt': + return currentNumber > target + case 'gte': + return currentNumber >= target + case 'lt': + return currentNumber < target + case 'lte': + return currentNumber <= target + case 'eq': + return currentNumber === target + case 'neq': + return currentNumber !== target + case 'crosses_above': + return previousNumber !== null && previousNumber <= target && currentNumber > target + case 'crosses_below': + return previousNumber !== null && previousNumber >= target && currentNumber < target + case 'changes_since_previous_by_abs': + return previousNumber !== null && Math.abs(currentNumber - previousNumber) >= Math.abs(target) + case 'changes_since_previous_by_percent': + return ( + previousNumber !== null && + previousNumber !== 0 && + Math.abs(((currentNumber - previousNumber) / previousNumber) * 100) >= Math.abs(target) + ) + default: + return false + } +} + +const evaluateNode = (node: PortfolioConditionNode, context: EvaluationContext): boolean => { + if (!isGroup(node)) return evaluateRule(node, context) + if (node.rules.length === 0) return false + + return node.combinator === 'or' + ? node.rules.some((rule) => evaluateNode(rule, context)) + : node.rules.every((rule) => evaluateNode(rule, context)) +} + +export const evaluatePortfolioFireCondition = ({ + condition, + current, + previous, +}: { + condition: PortfolioFireCondition + current: PortfolioConditionSnapshot + previous?: PortfolioConditionSnapshot | null +}) => evaluateNode(condition.root, { current, previous }) diff --git a/apps/tradinggoose/lib/monitors/portfolio-config.ts b/apps/tradinggoose/lib/monitors/portfolio-config.ts new file mode 100644 index 000000000..6890ec29b --- /dev/null +++ b/apps/tradinggoose/lib/monitors/portfolio-config.ts @@ -0,0 +1,167 @@ +import { z } from 'zod' +import { toListingValueObject } from '@/lib/listing/identity' +import { + isPortfolioConditionOperatorCompatible, + isPortfolioConditionValuelessOperator, + PORTFOLIO_CONDITION_METRICS, + PORTFOLIO_CONDITION_OPERATORS, + type PortfolioFireCondition, + portfolioConditionRequiresListing, +} from '@/lib/monitors/portfolio-conditions' +import { PORTFOLIO_MONITOR_PROVIDER, PORTFOLIO_MONITOR_TRIGGER_ID } from '@/lib/monitors/sources' +import type { TradingProviderId } from '@/providers/trading/types' + +const nonEmptyString = z.string().trim().min(1) +const tradingProviderId = nonEmptyString.transform((value) => value as TradingProviderId) +const PortfolioConditionSnapshotSchema = z + .object({ + summary: z.unknown(), + positions: z.array(z.unknown()), + }) + .strict() + +const PortfolioConditionRuleSchema: z.ZodType = z + .object({ + id: z.string().optional(), + metric: z.enum(PORTFOLIO_CONDITION_METRICS), + operator: z.enum(PORTFOLIO_CONDITION_OPERATORS), + value: z.union([z.number().finite(), z.string(), z.boolean(), z.null()]).optional(), + listing: z.unknown().nullish(), + }) + .refine( + (rule) => + isPortfolioConditionOperatorCompatible(rule.metric, rule.operator) && + (portfolioConditionRequiresListing(rule.metric) + ? Boolean(toListingValueObject(rule.listing)) + : rule.listing == null), + { message: 'Invalid portfolio condition rule' } + ) + .transform((rule) => ({ + id: rule.id, + metric: rule.metric, + operator: rule.operator, + value: isPortfolioConditionValuelessOperator(rule.operator) ? null : rule.value, + listing: portfolioConditionRequiresListing(rule.metric) + ? toListingValueObject(rule.listing) + : null, + })) + +const PortfolioConditionNodeSchema: z.ZodType = z.lazy(() => + z.union([ + PortfolioConditionRuleSchema, + z.object({ + id: z.string().optional(), + combinator: z.enum(['and', 'or']), + rules: z.array(PortfolioConditionNodeSchema).min(1), + }), + ]) +) + +export const PortfolioFireConditionSchema: z.ZodType = z.object({ + root: z.object({ + id: z.string().optional(), + combinator: z.enum(['and', 'or']), + rules: z.array(PortfolioConditionNodeSchema).min(1), + }), +}) + +export const PortfolioMonitorCreateSchema = z.object({ + source: z.literal(PORTFOLIO_MONITOR_PROVIDER), + workspaceId: nonEmptyString, + workflowId: nonEmptyString, + blockId: nonEmptyString, + providerId: nonEmptyString, + serviceId: nonEmptyString, + credentialId: nonEmptyString, + accountId: nonEmptyString, + condition: PortfolioFireConditionSchema, + fireMode: z.enum(['edge', 'while_true']).default('edge'), + cooldownSeconds: z.number().int().min(0).max(86_400).default(300), + pollIntervalSeconds: z.number().int().min(15).max(3600).default(60), + isActive: z.boolean().optional(), +}) + +export const PortfolioMonitorUpdateSchema = z.object({ + source: z.literal(PORTFOLIO_MONITOR_PROVIDER).optional(), + workspaceId: nonEmptyString, + workflowId: nonEmptyString.optional(), + blockId: nonEmptyString.optional(), + providerId: nonEmptyString.optional(), + serviceId: nonEmptyString.optional(), + credentialId: nonEmptyString.optional(), + accountId: nonEmptyString.optional(), + condition: PortfolioFireConditionSchema.optional(), + fireMode: z.enum(['edge', 'while_true']).optional(), + cooldownSeconds: z.number().int().min(0).max(86_400).optional(), + pollIntervalSeconds: z.number().int().min(15).max(3600).optional(), + isActive: z.boolean().optional(), +}) + +export const PortfolioMonitorProviderConfigSchema = z + .object({ + triggerId: z.literal(PORTFOLIO_MONITOR_TRIGGER_ID), + version: z.literal(1), + monitor: z + .object({ + triggerBlockId: nonEmptyString, + providerId: tradingProviderId, + serviceId: nonEmptyString, + credentialId: nonEmptyString, + connectionOwnerUserId: nonEmptyString, + accountId: nonEmptyString, + condition: PortfolioFireConditionSchema, + fireMode: z.enum(['edge', 'while_true']), + cooldownSeconds: z.number().int().min(0).max(86_400), + pollIntervalSeconds: z.number().int().min(15).max(3600), + }) + .strict(), + runtimeState: z + .object({ + lastEvaluatedAt: z.string().optional(), + lastFiredAt: z.string().optional(), + wasTrue: z.boolean().optional(), + previousSnapshot: PortfolioConditionSnapshotSchema.optional(), + }) + .strict() + .optional(), + }) + .strict() + +export type PortfolioMonitorProviderConfig = z.infer + +export const normalizePortfolioMonitorConfig = (input: { + triggerBlockId: string + providerId: string + serviceId: string + credentialId: string + connectionOwnerUserId: string + accountId: string + condition: PortfolioFireCondition + fireMode?: 'edge' | 'while_true' + cooldownSeconds?: number + pollIntervalSeconds?: number +}): PortfolioMonitorProviderConfig => ({ + triggerId: PORTFOLIO_MONITOR_TRIGGER_ID, + version: 1, + monitor: { + triggerBlockId: input.triggerBlockId, + providerId: input.providerId as TradingProviderId, + serviceId: input.serviceId, + credentialId: input.credentialId, + connectionOwnerUserId: input.connectionOwnerUserId, + accountId: input.accountId, + condition: input.condition, + fireMode: input.fireMode ?? 'edge', + cooldownSeconds: input.cooldownSeconds ?? 300, + pollIntervalSeconds: input.pollIntervalSeconds ?? 60, + }, +}) + +export const toPublicPortfolioMonitorProviderConfig = (config: PortfolioMonitorProviderConfig) => { + const { connectionOwnerUserId: _connectionOwnerUserId, ...monitor } = config.monitor + return { + triggerId: config.triggerId, + version: config.version, + monitor, + } +} diff --git a/apps/tradinggoose/lib/monitors/sources.ts b/apps/tradinggoose/lib/monitors/sources.ts new file mode 100644 index 000000000..0b7c2ac51 --- /dev/null +++ b/apps/tradinggoose/lib/monitors/sources.ts @@ -0,0 +1,74 @@ +export const INDICATOR_MONITOR_PROVIDER = 'indicator' as const +export const PORTFOLIO_MONITOR_PROVIDER = 'portfolio' as const + +export const INDICATOR_MONITOR_TRIGGER_ID = 'indicator_trigger' as const +export const PORTFOLIO_MONITOR_TRIGGER_ID = 'portfolio_state_trigger' as const + +export const MONITOR_SOURCES = [ + { + provider: INDICATOR_MONITOR_PROVIDER, + triggerId: INDICATOR_MONITOR_TRIGGER_ID, + label: 'Indicator trigger', + triggerLabel: 'Indicator Trigger', + }, + { + provider: PORTFOLIO_MONITOR_PROVIDER, + triggerId: PORTFOLIO_MONITOR_TRIGGER_ID, + label: 'Portfolio state', + triggerLabel: 'Portfolio State Trigger', + }, +] as const + +export const MONITOR_WEBHOOK_PROVIDERS = MONITOR_SOURCES.map((source) => source.provider) +export const MONITOR_TRIGGER_IDS = MONITOR_SOURCES.map((source) => source.triggerId) + +export type MonitorWebhookProvider = (typeof MONITOR_WEBHOOK_PROVIDERS)[number] +export type MonitorTriggerId = (typeof MONITOR_TRIGGER_IDS)[number] +export type MonitorSourceDefinition = (typeof MONITOR_SOURCES)[number] +export type MonitorProviderConfigEnvelope = { + triggerId: MonitorTriggerId + version: 1 + monitor: Record + runtimeState?: unknown +} + +const MONITOR_PROVIDER_SET = new Set(MONITOR_WEBHOOK_PROVIDERS) +const MONITOR_TRIGGER_ID_SET = new Set(MONITOR_TRIGGER_IDS) +const MONITOR_SOURCE_BY_PROVIDER = new Map( + MONITOR_SOURCES.map((source) => [source.provider, source]) +) +const MONITOR_SOURCE_BY_TRIGGER = new Map( + MONITOR_SOURCES.map((source) => [source.triggerId, source]) +) +const isRecord = (value: unknown): value is Record => + Boolean(value && typeof value === 'object' && !Array.isArray(value)) + +export const isMonitorProvider = (provider: unknown): provider is MonitorWebhookProvider => + typeof provider === 'string' && MONITOR_PROVIDER_SET.has(provider) + +export const isMonitorTriggerId = (triggerId: unknown): triggerId is MonitorTriggerId => + typeof triggerId === 'string' && MONITOR_TRIGGER_ID_SET.has(triggerId) + +export const isMonitorProviderConfigForProvider = ( + providerConfig: unknown, + provider: MonitorWebhookProvider +): providerConfig is MonitorProviderConfigEnvelope => + isRecord(providerConfig) && + providerConfig.triggerId === getMonitorTriggerIdForProvider(provider) && + providerConfig.version === 1 && + isRecord(providerConfig.monitor) + +export const getMonitorSourceByProvider = ( + provider: MonitorWebhookProvider +): MonitorSourceDefinition => MONITOR_SOURCE_BY_PROVIDER.get(provider)! + +export const getMonitorSourceByTriggerId = (triggerId: MonitorTriggerId): MonitorSourceDefinition => + MONITOR_SOURCE_BY_TRIGGER.get(triggerId)! + +export const getMonitorProviderForTriggerId = ( + triggerId: MonitorTriggerId +): MonitorWebhookProvider => getMonitorSourceByTriggerId(triggerId).provider + +export const getMonitorTriggerIdForProvider = ( + provider: MonitorWebhookProvider +): MonitorTriggerId => getMonitorSourceByProvider(provider).triggerId diff --git a/apps/tradinggoose/lib/trading/context.ts b/apps/tradinggoose/lib/trading/context.ts index 6138965eb..78349bb98 100644 --- a/apps/tradinggoose/lib/trading/context.ts +++ b/apps/tradinggoose/lib/trading/context.ts @@ -15,13 +15,14 @@ const logger = createLogger('TradingServices') type ProviderRequestData = { provider: string - tokenAccountId: string + credentialId: string serviceId: string } type PreflightContext = { requestId: string providerId: string + credentialId: string tokenAccountId: string serviceId: string environment: 'paper' | 'live' @@ -45,14 +46,15 @@ const requireStringField = (input: string | undefined, field: string): string => } export async function authorizeTradingConnectionRequest(params: { - tokenAccountId: string + credentialId: string userId: string }): Promise<{ connectionOwnerUserId: string + tokenAccountId: string accountProviderId: string }> { const connection = await resolveOAuthConnectionAccountForUser({ - accountId: params.tokenAccountId, + accountId: params.credentialId, userId: params.userId, }) if (!connection) { @@ -61,6 +63,7 @@ export async function authorizeTradingConnectionRequest(params: { return { connectionOwnerUserId: connection.credentialOwnerUserId, + tokenAccountId: connection.tokenAccountId, accountProviderId: connection.providerId, } } @@ -70,12 +73,14 @@ export async function resolveTradingProviderContext({ requestId, userId, connectionOwnerUserId, + tokenAccountId, accountProviderId, }: { requestData: ProviderRequestData requestId: string userId: string connectionOwnerUserId: string + tokenAccountId: string accountProviderId: string }): Promise { const providerId = requireStringField(requestData.provider, 'provider') @@ -91,13 +96,14 @@ export async function resolveTradingProviderContext({ throw new TradingServiceError('Trading provider connection is required') } - const tokenAccountId = requireStringField(requestData.tokenAccountId, 'tokenAccountId') + const credentialId = requireStringField(requestData.credentialId, 'credentialId') + const resolvedTokenAccountId = requireStringField(tokenAccountId, 'tokenAccountId') if (accountProviderId !== serviceId) { throw new TradingServiceError('Trading provider connection does not match requested service') } const resolvedAccessToken = await refreshAccessTokenIfNeeded( - tokenAccountId, + resolvedTokenAccountId, connectionOwnerUserId, requestId ) @@ -112,7 +118,8 @@ export async function resolveTradingProviderContext({ return { requestId, providerId, - tokenAccountId, + credentialId, + tokenAccountId: resolvedTokenAccountId, serviceId: serviceId, environment, accessToken: resolvedAccessToken, @@ -134,7 +141,7 @@ export async function resolveTradingProviderSelectedAccount({ const portfolioIdentity = portfolioIdentities.find( (candidate) => candidate.providerId === baseContext.providerId && - candidate.tokenAccountId === baseContext.tokenAccountId && + candidate.credentialId === baseContext.credentialId && candidate.serviceId === baseContext.serviceId && candidate.accountId === selectedAccountId ) diff --git a/apps/tradinggoose/lib/trading/order-detail.ts b/apps/tradinggoose/lib/trading/order-detail.ts index 4411d0814..070f04b36 100644 --- a/apps/tradinggoose/lib/trading/order-detail.ts +++ b/apps/tradinggoose/lib/trading/order-detail.ts @@ -10,8 +10,8 @@ import { TradingServiceError } from '@/lib/trading/errors' import { deepRedactSecrets, readOrderAccountId, + readOrderCredentialId, readOrderServiceId, - readOrderTokenAccountId, } from '@/lib/trading/order-records' import { executeTradingProviderOrderDetailRequest } from '@/providers/trading' import { TradingBrokerRequestError } from '@/providers/trading/portfolio-utils' @@ -66,25 +66,26 @@ export async function getRecordedTradingOrderProviderDetail({ throw new TradingServiceError('Tradier order history record is missing accountId') } - const tokenAccountId = readOrderTokenAccountId(order) + const credentialId = readOrderCredentialId(order) const serviceId = readOrderServiceId(order) - if (!tokenAccountId || !serviceId) { + if (!credentialId || !serviceId) { throw new TradingServiceError('Order history record is missing trading connection context') } const connectionAuthorization = await authorizeTradingConnectionRequest({ - tokenAccountId, + credentialId, userId, }) const baseContext = await resolveTradingProviderContext({ requestData: { provider: order.provider, - tokenAccountId, + credentialId, serviceId, }, requestId, userId, connectionOwnerUserId: connectionAuthorization.connectionOwnerUserId, + tokenAccountId: connectionAuthorization.tokenAccountId, accountProviderId: connectionAuthorization.accountProviderId, }) diff --git a/apps/tradinggoose/lib/trading/order-records.test.ts b/apps/tradinggoose/lib/trading/order-records.test.ts index ba78c459b..abf40c7f9 100644 --- a/apps/tradinggoose/lib/trading/order-records.test.ts +++ b/apps/tradinggoose/lib/trading/order-records.test.ts @@ -94,7 +94,7 @@ describe('order record utils', () => { request: { accountId: 'account-1', accessToken: 'secret-token', - tokenAccountId: 'oauth-account-1', + credentialId: 'oauth-account-1', serviceId: 'alpaca-paper', orderType: 'limit', quantity: '5', @@ -143,7 +143,7 @@ describe('order record utils', () => { expect(record.request).toMatchObject({ accountId: '[redacted]', accessToken: '[redacted]', - tokenAccountId: '[redacted]', + credentialId: '[redacted]', serviceId: '[redacted]', }) expect(record.response).toMatchObject({ diff --git a/apps/tradinggoose/lib/trading/order-records.ts b/apps/tradinggoose/lib/trading/order-records.ts index f6c1cf3be..25cf5c3b2 100644 --- a/apps/tradinggoose/lib/trading/order-records.ts +++ b/apps/tradinggoose/lib/trading/order-records.ts @@ -249,8 +249,8 @@ const readOrderRequestString = (row: Pick, key: stri export const readOrderAccountId = (row: Pick) => readOrderRequestString(row, 'accountId') -export const readOrderTokenAccountId = (row: Pick) => - readOrderRequestString(row, 'tokenAccountId') +export const readOrderCredentialId = (row: Pick) => + readOrderRequestString(row, 'credentialId') export const readOrderServiceId = (row: Pick) => readOrderRequestString(row, 'serviceId') diff --git a/apps/tradinggoose/lib/trading/orders.ts b/apps/tradinggoose/lib/trading/orders.ts index bd5969bf4..a096a564b 100644 --- a/apps/tradinggoose/lib/trading/orders.ts +++ b/apps/tradinggoose/lib/trading/orders.ts @@ -343,19 +343,20 @@ export async function submitTradingOrder({ logId: requestData.logId, }) const connectionAuthorization = await authorizeTradingConnectionRequest({ - tokenAccountId: portfolioIdentity.tokenAccountId, + credentialId: portfolioIdentity.credentialId, userId, }) const baseContext = await resolveTradingProviderContext({ requestData: { provider: portfolioIdentity.providerId, - tokenAccountId: portfolioIdentity.tokenAccountId, + credentialId: portfolioIdentity.credentialId, serviceId: portfolioIdentity.serviceId, }, requestId, userId, connectionOwnerUserId: connectionAuthorization.connectionOwnerUserId, + tokenAccountId: connectionAuthorization.tokenAccountId, accountProviderId: connectionAuthorization.accountProviderId, }) @@ -390,7 +391,7 @@ export async function submitTradingOrder({ }) const clientOrderId = createTradingOrderClientOrderId(requestData.idempotencyKey) const orderHistoryRequest = compactRecord({ - tokenAccountId: baseContext.tokenAccountId, + credentialId: baseContext.credentialId, serviceId: baseContext.serviceId, accountId: accountContext.accountId, clientOrderId, diff --git a/apps/tradinggoose/lib/trading/holdings.ts b/apps/tradinggoose/lib/trading/portfolio-detail.ts similarity index 67% rename from apps/tradinggoose/lib/trading/holdings.ts rename to apps/tradinggoose/lib/trading/portfolio-detail.ts index f18d10553..260576ee6 100644 --- a/apps/tradinggoose/lib/trading/holdings.ts +++ b/apps/tradinggoose/lib/trading/portfolio-detail.ts @@ -1,3 +1,4 @@ +import { checkWorkspaceAccess } from '@/lib/permissions/utils' import { authorizeTradingConnectionRequest, resolveTradingProviderContext, @@ -9,44 +10,55 @@ import { toPortfolioValueObject } from '@/providers/trading/portfolio-identity' import { getTradingProviderDefinition } from '@/providers/trading/providers' import { TradingServiceError } from './errors' -export interface TradingHoldingsRequest { +export interface TradingPortfolioDetailRequest { + workspaceId?: string portfolioIdentity?: PortfolioIdentity | null } -export type TradingHoldingsResult = { +export type TradingPortfolioDetailResult = { summary: string provider: string - holdings: PortfolioDetail + portfolioDetail: PortfolioDetail } -export async function getTradingHoldings({ +export async function getTradingPortfolioDetail({ requestData, requestId, userId, }: { - requestData: TradingHoldingsRequest + requestData: TradingPortfolioDetailRequest requestId: string userId: string -}): Promise { +}): Promise { const portfolioIdentity = toPortfolioValueObject(requestData.portfolioIdentity) if (!portfolioIdentity) { throw new TradingServiceError('Portfolio identity is required') } + const workspaceId = requestData.workspaceId?.trim() + if (!workspaceId) { + throw new TradingServiceError('workspaceId is required') + } + const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) + if (!workspaceAccess.exists || !workspaceAccess.hasAccess) { + throw new TradingServiceError('Not found', 404) + } + const connectionAuthorization = await authorizeTradingConnectionRequest({ - tokenAccountId: portfolioIdentity.tokenAccountId, + credentialId: portfolioIdentity.credentialId, userId, }) const baseContext = await resolveTradingProviderContext({ requestData: { provider: portfolioIdentity.providerId, - tokenAccountId: portfolioIdentity.tokenAccountId, + credentialId: portfolioIdentity.credentialId, serviceId: portfolioIdentity.serviceId, }, requestId, userId, connectionOwnerUserId: connectionAuthorization.connectionOwnerUserId, + tokenAccountId: connectionAuthorization.tokenAccountId, accountProviderId: connectionAuthorization.accountProviderId, }) const providerDefinition = getTradingProviderDefinition(baseContext.providerId) @@ -58,8 +70,9 @@ export async function getTradingHoldings({ accountId: portfolioIdentity.accountId, }) - const holdings = await getPortfolioDetail({ + const portfolioDetail = await getPortfolioDetail({ providerId: baseContext.providerId, + credentialId: baseContext.credentialId, tokenAccountId: baseContext.tokenAccountId, serviceId: baseContext.serviceId, environment: baseContext.environment, @@ -70,6 +83,6 @@ export async function getTradingHoldings({ return { summary: `Fetched portfolio detail from ${providerDefinition.name}`, provider: baseContext.providerId, - holdings, + portfolioDetail, } } diff --git a/apps/tradinggoose/lib/trading/portfolio-identities.test.ts b/apps/tradinggoose/lib/trading/portfolio-identities.test.ts index a83622b77..76a6f8021 100644 --- a/apps/tradinggoose/lib/trading/portfolio-identities.test.ts +++ b/apps/tradinggoose/lib/trading/portfolio-identities.test.ts @@ -10,6 +10,14 @@ const mocks = vi.hoisted(() => ({ providerId: string credentialOwnerUserId: string }>, + connectionById: new Map< + string, + { + tokenAccountId: string + providerId: string + credentialOwnerUserId: string + } + >(), refreshAccessTokenIfNeeded: vi.fn(), listPortfolioIdentities: vi.fn(), })) @@ -20,6 +28,9 @@ vi.mock('@/lib/oauth/tokens', () => ({ vi.mock('@/lib/credentials/oauth', () => ({ listOAuthConnectionAccountsForUser: vi.fn(() => Promise.resolve(mocks.connections)), + resolveOAuthConnectionAccountForUser: vi.fn(({ accountId }: { accountId: string }) => + Promise.resolve(mocks.connectionById.get(accountId) ?? null) + ), })) vi.mock('@/providers/trading/portfolio', () => ({ @@ -45,7 +56,7 @@ vi.mock('@/providers/trading/providers', () => ({ const portfolioIdentity = { providerId: 'alpaca', - tokenAccountId: 'account-live', + credentialId: 'connection-live', serviceId: 'alpaca-live', accountId: 'account-1', } @@ -54,25 +65,48 @@ describe('listTradingPortfolioIdentities', () => { beforeEach(() => { vi.clearAllMocks() mocks.connections = [] + mocks.connectionById = new Map() mocks.refreshAccessTokenIfNeeded.mockResolvedValue('token') mocks.listPortfolioIdentities.mockResolvedValue([portfolioIdentity]) }) - it('throws for a selected service when any same-service account load fails', async () => { + it('throws when no selected connection identities can be resolved', async () => { + mocks.connectionById.set('connection-stale', { + tokenAccountId: 'connection-stale', + providerId: 'alpaca-live', + credentialOwnerUserId: 'user-1', + }) + mocks.refreshAccessTokenIfNeeded.mockImplementation((tokenAccountId: string) => + tokenAccountId === 'connection-stale' ? null : 'token' + ) + const { listTradingPortfolioIdentities } = await import('./portfolio-identities') + + await expect( + listTradingPortfolioIdentities({ + userId: 'user-1', + providerId: 'alpaca', + serviceId: 'alpaca-live', + credentialId: 'connection-stale', + requestId: 'request-1', + }) + ).rejects.toThrow('Trading connection token unavailable: connection-stale') + }) + + it('returns identities from healthy connections when another connection fails', async () => { mocks.connections = [ { - tokenAccountId: 'account-live', + tokenAccountId: 'connection-live', providerId: 'alpaca-live', credentialOwnerUserId: 'user-1', }, { - tokenAccountId: 'account-stale', - providerId: 'alpaca-live', + tokenAccountId: 'connection-paper', + providerId: 'alpaca-paper', credentialOwnerUserId: 'user-1', }, ] mocks.refreshAccessTokenIfNeeded.mockImplementation((tokenAccountId: string) => - tokenAccountId === 'account-stale' ? null : 'token' + tokenAccountId === 'connection-paper' ? null : 'token' ) const { listTradingPortfolioIdentities } = await import('./portfolio-identities') @@ -80,28 +114,19 @@ describe('listTradingPortfolioIdentities', () => { listTradingPortfolioIdentities({ userId: 'user-1', providerId: 'alpaca', - serviceId: 'alpaca-live', requestId: 'request-1', }) - ).rejects.toThrow('Failed to load trading portfolio identities') + ).resolves.toEqual([portfolioIdentity]) }) - it('returns healthy identities when another service fails during all-service loading', async () => { + it('returns identities for all owned trading connections', async () => { mocks.connections = [ { - tokenAccountId: 'account-live', + tokenAccountId: 'connection-live', providerId: 'alpaca-live', credentialOwnerUserId: 'user-1', }, - { - tokenAccountId: 'account-paper', - providerId: 'alpaca-paper', - credentialOwnerUserId: 'user-1', - }, ] - mocks.refreshAccessTokenIfNeeded.mockImplementation((tokenAccountId: string) => - tokenAccountId === 'account-paper' ? null : 'token' - ) const { listTradingPortfolioIdentities } = await import('./portfolio-identities') await expect( diff --git a/apps/tradinggoose/lib/trading/portfolio-identities.ts b/apps/tradinggoose/lib/trading/portfolio-identities.ts index b61ad2bb2..42ef74656 100644 --- a/apps/tradinggoose/lib/trading/portfolio-identities.ts +++ b/apps/tradinggoose/lib/trading/portfolio-identities.ts @@ -1,4 +1,7 @@ -import { listOAuthConnectionAccountsForUser } from '@/lib/credentials/oauth' +import { + listOAuthConnectionAccountsForUser, + resolveOAuthConnectionAccountForUser, +} from '@/lib/credentials/oauth' import { refreshAccessTokenIfNeeded } from '@/lib/oauth/tokens' import { listPortfolioIdentities } from '@/providers/trading/portfolio' import { @@ -8,66 +11,110 @@ import { } from '@/providers/trading/providers' import type { TradingProviderId } from '@/providers/trading/types' +type OAuthConnectionAccount = { + tokenAccountId: string + providerId: string + credentialOwnerUserId: string +} + +async function listConnectionPortfolioIdentities({ + connection, + providerId, + requestId, +}: { + connection: OAuthConnectionAccount + providerId: TradingProviderId + requestId: string +}) { + const environment = getTradingProviderOAuthEnvironment(providerId, connection.providerId) + if (!environment) { + throw new Error(`Unsupported trading service: ${connection.providerId}`) + } + + const accessToken = await refreshAccessTokenIfNeeded( + connection.tokenAccountId, + connection.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + throw new Error(`Trading connection token unavailable: ${connection.tokenAccountId}`) + } + + return listPortfolioIdentities({ + providerId, + credentialId: connection.tokenAccountId, + tokenAccountId: connection.tokenAccountId, + serviceId: connection.providerId, + environment, + accessToken, + }) +} + export async function listTradingPortfolioIdentities({ userId, providerId, serviceId, + credentialId, requestId, }: { userId: string providerId: TradingProviderId serviceId?: string + credentialId?: string requestId: string }) { const provider = getTradingProviderDefinition(providerId) - const services = provider?.oauth?.services ?? [] + if (!provider) throw new Error('Unsupported trading provider') + + const services = provider.oauth?.services ?? [] const serviceIds = services.map(({ serviceId }) => serviceId) const selectedServiceId = serviceId ? getTradingProviderOAuthServiceId(providerId, serviceId) : undefined - if (serviceId && !selectedServiceId) return [] + if (serviceId && !selectedServiceId) throw new Error('Trading provider connection is required') const targetServiceIds = selectedServiceId ? [selectedServiceId] : serviceIds if (!targetServiceIds.length) return [] + if (credentialId) { + const connection = await resolveOAuthConnectionAccountForUser({ + accountId: credentialId, + userId, + }) + if (!connection || !targetServiceIds.includes(connection.providerId)) { + throw new Error(`Trading connection unavailable: ${credentialId}`) + } + return listConnectionPortfolioIdentities({ + connection, + providerId, + requestId, + }) + } + const connections = await listOAuthConnectionAccountsForUser({ userId, providerIds: targetServiceIds, }) + if (!connections.length) return [] - const identities = await Promise.allSettled( - connections.map(async (connection) => { - const environment = getTradingProviderOAuthEnvironment(providerId, connection.providerId) - if (!environment) { - throw new Error(`Unsupported trading service: ${connection.providerId}`) - } - - const accessToken = await refreshAccessTokenIfNeeded( - connection.tokenAccountId, - connection.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - throw new Error(`Trading connection token unavailable: ${connection.tokenAccountId}`) - } - - return listPortfolioIdentities({ - providerId, - tokenAccountId: connection.tokenAccountId, - serviceId: connection.providerId, - environment, - accessToken, - }) + const identityRequests = connections.map((connection) => + listConnectionPortfolioIdentities({ + connection, + providerId, + requestId, }) ) - const fulfilled = identities.flatMap((result) => - result.status === 'fulfilled' ? [result.value] : [] + const settled = await Promise.allSettled(identityRequests) + const identities = settled.flatMap((result) => + result.status === 'fulfilled' ? result.value : [] ) - const hasRejectedIdentityLoad = identities.some((result) => result.status === 'rejected') - if ((serviceId || !fulfilled.length) && hasRejectedIdentityLoad) { - throw new Error('Failed to load trading portfolio identities') - } + if (identities.length > 0) return identities + + const firstFailure = settled.find( + (result): result is PromiseRejectedResult => result.status === 'rejected' + ) + if (firstFailure) throw firstFailure.reason - return fulfilled.flat() + return [] } diff --git a/apps/tradinggoose/lib/webhooks/utils.ts b/apps/tradinggoose/lib/webhooks/utils.ts index f543ea878..9a72167bd 100644 --- a/apps/tradinggoose/lib/webhooks/utils.ts +++ b/apps/tradinggoose/lib/webhooks/utils.ts @@ -4,6 +4,7 @@ import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getOAuthAccessTokenForStoredCredential } from '@/lib/credentials/oauth' import { createLogger } from '@/lib/logs/console/logger' +import { isMonitorProvider } from '@/lib/monitors/sources' const logger = createLogger('WebhookUtils') @@ -526,7 +527,7 @@ export async function formatWebhookInput( body: any, request: NextRequest ): Promise { - if (foundWebhook.provider === 'indicator') { + if (isMonitorProvider(foundWebhook.provider)) { return body } diff --git a/apps/tradinggoose/lib/workflows/db-helpers.test.ts b/apps/tradinggoose/lib/workflows/db-helpers.test.ts index f3a46ae07..40e0d3f31 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.test.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.test.ts @@ -19,11 +19,6 @@ const mockDb = { transaction: vi.fn(), } -const mockWebhook = { - workflowId: 'workflowId', - provider: 'provider', -} - const mockWorkflowTable = { id: 'id', variables: 'variables', @@ -78,7 +73,6 @@ const mockWorkflowSubflows = { vi.doMock('@tradinggoose/db', () => ({ db: mockDb, - webhook: mockWebhook, workflow: mockWorkflowTable, workflowBlocks: mockWorkflowBlocks, workflowEdges: mockWorkflowEdges, diff --git a/apps/tradinggoose/lib/workflows/db-helpers.ts b/apps/tradinggoose/lib/workflows/db-helpers.ts index 0be063090..77e80b67f 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.ts @@ -1,15 +1,14 @@ import { db, - webhook, workflow, workflowBlocks, workflowDeploymentVersion, workflowEdges, workflowSubflows, } from '@tradinggoose/db' +import type { Edge } from '@xyflow/react' import type { InferSelectModel } from 'drizzle-orm' import { and, desc, eq, inArray, ne, sql } from 'drizzle-orm' -import type { Edge } from '@xyflow/react' import { v4 as uuidv4 } from 'uuid' import * as Y from 'yjs' import { reconcilePublishedChatsForDeploymentTx } from '@/lib/chat/published-deployment' @@ -18,12 +17,12 @@ import { serializeYjsTransportEnvelope, } from '@/lib/copilot/review-sessions/identity' import { createLogger } from '@/lib/logs/console/logger' +import { resolveStoredDateValue } from '@/lib/time-format' +import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/validation' +import { normalizeVariables } from '@/lib/workflows/variable-utils' import { inferMermaidDirectionFromWorkflowState } from '@/lib/workflows/workflow-direction' import { getYjsSnapshot, SocketServerBridgeError } from '@/lib/yjs/server/snapshot-bridge' import { extractPersistedStateFromDoc } from '@/lib/yjs/workflow-session' -import { resolveStoredDateValue } from '@/lib/time-format' -import { normalizeVariables } from '@/lib/workflows/variable-utils' -import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/validation' import type { Variable } from '@/stores/variables/types' import type { BlockState, @@ -225,12 +224,13 @@ export async function loadWorkflowState( } return { - direction: normalizedData.blocks && Object.keys(normalizedData.blocks).length > 0 - ? inferMermaidDirectionFromWorkflowState({ - blocks: normalizedData.blocks, - edges: normalizedData.edges, - }) - : undefined, + direction: + normalizedData.blocks && Object.keys(normalizedData.blocks).length > 0 + ? inferMermaidDirectionFromWorkflowState({ + blocks: normalizedData.blocks, + edges: normalizedData.edges, + }) + : undefined, blocks: normalizedData.blocks, edges: normalizedData.edges, loops: normalizedData.loops, @@ -898,14 +898,6 @@ export async function saveWorkflowToNormalizedTables( sql`select id from "workflow" where "workflow"."id" = ${workflowId} for update` ) - await tx - .update(webhook) - .set({ - blockId: null, - updatedAt: new Date(), - }) - .where(and(eq(webhook.workflowId, workflowId), eq(webhook.provider, 'indicator'))) - // Clear existing data for this workflow await Promise.all([ tx.delete(workflowBlocks).where(eq(workflowBlocks.workflowId, workflowId)), diff --git a/apps/tradinggoose/lib/workflows/execution-runner.test.ts b/apps/tradinggoose/lib/workflows/execution-runner.test.ts index b3579efdf..5784b9908 100644 --- a/apps/tradinggoose/lib/workflows/execution-runner.test.ts +++ b/apps/tradinggoose/lib/workflows/execution-runner.test.ts @@ -263,6 +263,25 @@ describe('runPreparedWorkflowExecution', () => { error: 'Usage limit exceeded', }) ) + expect(result.dispatchFailureReason).toBe('usage_limit_exceeded') + }) + + it('reports missing start blocks as dispatch failures', async () => { + const result = await runPreparedWorkflowExecution({ + blueprint, + actorUserId: 'user-1', + triggerType: 'webhook', + workflowInput: {}, + executionId: 'execution-1', + start: { + kind: 'block', + blockId: 'missing', + }, + }) + + expect(mocks.execute).not.toHaveBeenCalled() + expect(result.result.success).toBe(false) + expect(result.dispatchFailureReason).toBe('missing_start_block') }) it('does not rewrite successful executions as failed when terminal success logging fails', async () => { diff --git a/apps/tradinggoose/lib/workflows/execution-runner.ts b/apps/tradinggoose/lib/workflows/execution-runner.ts index a5bbf5dbf..c31f074b7 100644 --- a/apps/tradinggoose/lib/workflows/execution-runner.ts +++ b/apps/tradinggoose/lib/workflows/execution-runner.ts @@ -58,12 +58,14 @@ export type WorkflowExecutionBlueprint = { } export type WorkflowRunnerExecutionResult = ExecutionResult +export type WorkflowDispatchFailureReason = 'usage_limit_exceeded' | 'missing_start_block' export type WorkflowRunnerResult = { executionId: string result: WorkflowRunnerExecutionResult workflowData: WorkflowExecutionBlueprint['workflowData'] workspaceId: string + dispatchFailureReason?: WorkflowDispatchFailureReason } export class WorkflowUsageLimitError extends Error { @@ -76,6 +78,8 @@ export class WorkflowUsageLimitError extends Error { } } +class WorkflowStartBlockError extends Error {} + async function resolveRequiredWorkflowExecutionContext( workflowId: string, workflowContext?: WorkflowContextHint @@ -209,7 +213,7 @@ function resolveStartBlockId(params: { : params.start.triggerType === 'chat' ? 'Chat' : 'Manual' - throw new Error( + throw new WorkflowStartBlockError( `No ${triggerName} trigger block found. Add a ${triggerName} Trigger block to this workflow.` ) } @@ -219,7 +223,9 @@ function resolveStartBlockId(params: { ) if (outgoingConnections.length === 0) { - throw new Error('Trigger block must be connected to other blocks to execute') + throw new WorkflowStartBlockError( + 'Trigger block must be connected to other blocks to execute' + ) } return startBlock.blockId @@ -230,7 +236,9 @@ function resolveStartBlockId(params: { params.start.blockId && !params.mergedStates[params.start.blockId] ) { - throw new Error(`Workflow does not contain trigger block ${params.start.blockId}`) + throw new WorkflowStartBlockError( + `Workflow does not contain trigger block ${params.start.blockId}` + ) } if (params.start.kind === 'block' && params.start.blockId) { @@ -240,7 +248,9 @@ function resolveStartBlockId(params: { ) if (outgoingConnections.length === 0) { - throw new Error(`Trigger block ${blockId} must be connected to other blocks to execute`) + throw new WorkflowStartBlockError( + `Trigger block ${blockId} must be connected to other blocks to execute` + ) } } @@ -394,6 +404,12 @@ export async function runPreparedWorkflowExecution(params: { } } catch (error: any) { const message = error.message || 'Workflow execution failed' + const dispatchFailureReason = + error instanceof WorkflowUsageLimitError + ? 'usage_limit_exceeded' + : error instanceof WorkflowStartBlockError + ? 'missing_start_block' + : undefined result = (error?.executionResult as ExecutionResult | undefined) || { success: false, output: {}, @@ -419,6 +435,7 @@ export async function runPreparedWorkflowExecution(params: { result, workflowData: params.blueprint.workflowData, workspaceId, + dispatchFailureReason, } } diff --git a/apps/tradinggoose/providers/market/alpaca/config.ts b/apps/tradinggoose/providers/market/alpaca/config.ts index f5ab857be..f4a8fbe1b 100644 --- a/apps/tradinggoose/providers/market/alpaca/config.ts +++ b/apps/tradinggoose/providers/market/alpaca/config.ts @@ -182,7 +182,6 @@ export const alpacaProviderConfig: MarketProviderConfig = { marketSessions: ['regular', 'extended'] }, live: { - supportsStreaming: true, channels: ['bars', 'trades', 'quotes'], supportsInterval: false, }, diff --git a/apps/tradinggoose/providers/market/alpha-vantage/config.ts b/apps/tradinggoose/providers/market/alpha-vantage/config.ts index b9fa3a5a2..ba5a3baa7 100644 --- a/apps/tradinggoose/providers/market/alpha-vantage/config.ts +++ b/apps/tradinggoose/providers/market/alpha-vantage/config.ts @@ -68,7 +68,6 @@ export const alphaVantageProviderConfig: MarketProviderConfig = { }, }, live: { - supportsPolling: true, channels: ['quote-snapshots'], supportsInterval: false, pollingIntervalMs: 60_000, diff --git a/apps/tradinggoose/providers/market/finnhub/config.ts b/apps/tradinggoose/providers/market/finnhub/config.ts index e5a127ef4..34d07101d 100644 --- a/apps/tradinggoose/providers/market/finnhub/config.ts +++ b/apps/tradinggoose/providers/market/finnhub/config.ts @@ -82,7 +82,6 @@ export const finnhubProviderConfig: MarketProviderConfig = { }, }, live: { - supportsStreaming: true, channels: ['trades', 'bars'], supportsInterval: false, }, diff --git a/apps/tradinggoose/providers/market/providers.ts b/apps/tradinggoose/providers/market/providers.ts index 54e12faf6..c29692a24 100644 --- a/apps/tradinggoose/providers/market/providers.ts +++ b/apps/tradinggoose/providers/market/providers.ts @@ -57,8 +57,6 @@ export interface MarketSeriesRetentionPolicy { } export interface MarketLiveInputCapabilities { - supportsStreaming?: boolean - supportsPolling?: boolean channels?: string[] supportsInterval?: boolean intervals?: string[] @@ -181,6 +179,12 @@ export interface MarketProviderDefinition { icon?: React.ComponentType<{ className?: string }> } +export type MarketProviderOption = { + id: string + name: string + icon?: React.ComponentType<{ className?: string }> +} + export const MARKET_PROVIDER_DEFINITIONS: Record = { 'alpha-vantage': { id: 'alpha-vantage', @@ -245,6 +249,15 @@ export function getMarketSeriesCapabilities( export function getMarketLiveCapabilities(providerId: string): MarketLiveInputCapabilities | null { return getMarketProviderCapabilities(providerId)?.live || null } + +export function getMarketProviderIntervals(providerId: string): MarketInterval[] { + return getMarketSeriesCapabilities(providerId)?.intervals ?? [] +} + +export function getMarketProviderPollingIntervalMs(providerId: string): number | undefined { + return getMarketLiveCapabilities(providerId)?.pollingIntervalMs +} + export function getMarketProviderExchangeCodes(providerId: string): string[] { return MARKET_PROVIDER_DEFINITIONS[providerId]?.config.exchangeCodes || [] } @@ -259,16 +272,14 @@ export function getMarketProviderKinds(providerId: string): MarketDataType[] { return Array.from(kinds) } -export function getMarketProviderOptions(): Array<{ - id: string - name: string - icon?: React.ComponentType<{ className?: string }> -}> { - return Object.values(MARKET_PROVIDER_DEFINITIONS).map((provider) => ({ - id: provider.id, - name: provider.name, - icon: provider.icon, - })) +const toMarketProviderOption = (provider: MarketProviderDefinition): MarketProviderOption => ({ + id: provider.id, + name: provider.name, + icon: provider.icon, +}) + +export function getMarketProviderOptions(): MarketProviderOption[] { + return Object.values(MARKET_PROVIDER_DEFINITIONS).map(toMarketProviderOption) } export function getMarketProvidersByKind(kind: MarketDataType): MarketProviderDefinition[] { @@ -279,16 +290,51 @@ export function getMarketProvidersByKind(kind: MarketDataType): MarketProviderDe }) } -export function getMarketProviderOptionsByKind(kind: MarketDataType): Array<{ - id: string - name: string - icon?: React.ComponentType<{ className?: string }> -}> { - return getMarketProvidersByKind(kind).map((provider) => ({ - id: provider.id, - name: provider.name, - icon: provider.icon, - })) +export function getMarketProviderOptionsByKind(kind: MarketDataType): MarketProviderOption[] { + return getMarketProvidersByKind(kind).map(toMarketProviderOption) +} + +export function getMarketMonitorProviderParamDefinitions( + providerId: string +): MarketProviderParamDefinition[] { + const definitions = [ + ...getMarketProviderParamDefinitions(providerId, 'series'), + ...getMarketProviderParamDefinitions(providerId, 'live'), + ] + const mergedById = new Map() + + definitions.forEach((definition) => { + if (!definition?.id) return + const existing = mergedById.get(definition.id) + if (!existing) { + mergedById.set(definition.id, definition) + return + } + + mergedById.set(definition.id, { + ...existing, + ...definition, + description: existing.description || definition.description, + title: existing.title || definition.title, + placeholder: existing.placeholder || definition.placeholder, + inputType: existing.inputType || definition.inputType, + options: existing.options?.length ? existing.options : definition.options, + fetchOptions: existing.fetchOptions ?? definition.fetchOptions, + password: existing.password ?? definition.password, + mode: existing.mode || definition.mode, + layout: existing.layout || definition.layout, + min: existing.min ?? definition.min, + max: existing.max ?? definition.max, + step: existing.step ?? definition.step, + integer: existing.integer ?? definition.integer, + rows: existing.rows ?? definition.rows, + dependsOn: existing.dependsOn ?? definition.dependsOn, + required: Boolean(existing.required || definition.required), + visibility: mergeParamVisibility(existing.visibility, definition.visibility), + }) + }) + + return Array.from(mergedById.values()) } export interface MarketProviderParamRegistryEntry { diff --git a/apps/tradinggoose/providers/market/yahoo-finance/config.ts b/apps/tradinggoose/providers/market/yahoo-finance/config.ts index bb878eceb..385392aff 100644 --- a/apps/tradinggoose/providers/market/yahoo-finance/config.ts +++ b/apps/tradinggoose/providers/market/yahoo-finance/config.ts @@ -375,7 +375,6 @@ export const YahooFinanceProviderConfig: MarketProviderConfig = { }, }, live: { - supportsPolling: true, channels: ['quote-snapshots'], supportsInterval: false, pollingIntervalMs: 15_000, diff --git a/apps/tradinggoose/providers/trading/alpaca/accounts.ts b/apps/tradinggoose/providers/trading/alpaca/accounts.ts index c4c53226c..d75927e39 100644 --- a/apps/tradinggoose/providers/trading/alpaca/accounts.ts +++ b/apps/tradinggoose/providers/trading/alpaca/accounts.ts @@ -57,7 +57,7 @@ export const mapAlpacaAccountType = (account: any): UnifiedTradingAccountType => export const normalizeAlpacaTradingAccount = ( account: any, - context: Pick + context: Pick ): PortfolioIdentity => { const id = typeof account?.id === 'string' ? account.id.trim() : '' if (!id) { @@ -71,7 +71,7 @@ export const normalizeAlpacaTradingAccount = ( return { providerId: context.providerId, - tokenAccountId: context.tokenAccountId, + credentialId: context.credentialId, serviceId: context.serviceId, accountId: id, providerName: 'Alpaca', diff --git a/apps/tradinggoose/providers/trading/alpaca/config.ts b/apps/tradinggoose/providers/trading/alpaca/config.ts index ad715f540..6eca51b55 100644 --- a/apps/tradinggoose/providers/trading/alpaca/config.ts +++ b/apps/tradinggoose/providers/trading/alpaca/config.ts @@ -87,7 +87,7 @@ const marketToExchangeCodeMap: TradingProviderConfig['marketToExchangeCode'] = { const availability: TradingProviderConfig['availability'] = { assetClass: availableAssetClasses, order: true, - holdings: true, + portfolioDetail: true, availableCurrencyBase: [], availableCurrencyQuote: [], availableCryptoBase: availableCryptoBaseCodes, @@ -143,7 +143,7 @@ export const alpacaTradingProviderConfig: TradingProviderConfig = { ], timeInForce: ['day', 'gtc', 'ioc', 'fok'], }, - holdings: { + portfolioDetail: { performanceWindows: ['1D', '1W', '1M', '3M', 'YTD', '1Y'], }, }, diff --git a/apps/tradinggoose/providers/trading/alpaca/performance.ts b/apps/tradinggoose/providers/trading/alpaca/performance.ts index 5cce70ec4..7ac99b9f4 100644 --- a/apps/tradinggoose/providers/trading/alpaca/performance.ts +++ b/apps/tradinggoose/providers/trading/alpaca/performance.ts @@ -20,10 +20,7 @@ import type { UnifiedTradingPortfolioPerformancePoint, } from '@/providers/trading/types' -type AlpacaTradingPortfolioPerformanceWindow = Exclude< - TradingPortfolioPerformanceWindow, - 'MAX' -> +type AlpacaTradingPortfolioPerformanceWindow = Exclude const isAlpacaPerformanceWindowSupported = ( window: TradingPortfolioPerformanceWindow @@ -31,7 +28,7 @@ const isAlpacaPerformanceWindowSupported = ( window !== 'MAX' && getAlpacaSupportedPerformanceWindows().includes(window) const getAlpacaSupportedPerformanceWindows = () => - alpacaTradingProviderConfig.capabilities?.holdings?.performanceWindows ?? [] + alpacaTradingProviderConfig.capabilities?.portfolioDetail?.performanceWindows ?? [] const getNewYorkYear = (now: Date) => Number( diff --git a/apps/tradinggoose/providers/trading/alpaca/portfolio.test.ts b/apps/tradinggoose/providers/trading/alpaca/portfolio.test.ts index 9bb082fe6..29ca51e8d 100644 --- a/apps/tradinggoose/providers/trading/alpaca/portfolio.test.ts +++ b/apps/tradinggoose/providers/trading/alpaca/portfolio.test.ts @@ -23,8 +23,8 @@ vi.mock('@/providers/trading/listing-resolution', () => ({ describe('Alpaca portfolio helpers', () => { beforeEach(() => { vi.stubGlobal('fetch', vi.fn()) - resolveTradingListingIdentityMock.mockImplementation((symbol: { base: string }) => ({ - listing_id: symbol.base, + resolveTradingListingIdentityMock.mockImplementation((input: any) => ({ + listing_id: input?.listing?.listing_id ?? input?.listing?.base_id ?? '', base_id: '', quote_id: '', listing_type: 'default', @@ -48,13 +48,13 @@ describe('Alpaca portfolio helpers', () => { }, { providerId: 'alpaca', - tokenAccountId: 'oauth-account-1', + credentialId: 'oauth-credential-1', serviceId: 'alpaca-live', } ) ).toEqual({ providerId: 'alpaca', - tokenAccountId: 'oauth-account-1', + credentialId: 'oauth-credential-1', serviceId: 'alpaca-live', accountId: 'acct-live', providerName: 'Alpaca', @@ -78,7 +78,7 @@ describe('Alpaca portfolio helpers', () => { }, { providerId: 'alpaca', - tokenAccountId: 'oauth-account-1', + credentialId: 'oauth-credential-1', serviceId: 'alpaca-live', } ) @@ -133,6 +133,7 @@ describe('Alpaca portfolio helpers', () => { const snapshot = await getAlpacaTradingAccountSnapshot({ providerId: 'alpaca', + credentialId: 'oauth-credential-1', tokenAccountId: 'oauth-account-1', serviceId: 'alpaca-live', environment: 'live', @@ -151,7 +152,7 @@ describe('Alpaca portfolio helpers', () => { }) expect(snapshot.cashBalances[0]?.amount).toBe(2500) expect(snapshot.positions).toHaveLength(1) - expect(snapshot.positions[0]?.symbol.listing).toEqual({ + expect(snapshot.positions[0]?.listingIdentity).toEqual({ listing_id: 'AAPL', base_id: '', quote_id: '', @@ -201,6 +202,7 @@ describe('Alpaca portfolio helpers', () => { const snapshot = await getAlpacaTradingAccountSnapshot({ providerId: 'alpaca', + credentialId: 'oauth-credential-1', tokenAccountId: 'oauth-account-1', serviceId: 'alpaca-live', environment: 'live', @@ -217,7 +219,7 @@ describe('Alpaca portfolio helpers', () => { equity: 9000, }) expect(snapshot.positions[0]?.quantity).toBe(-25) - expect(snapshot.positions[0]?.symbol.listing).toEqual({ + expect(snapshot.positions[0]?.listingIdentity).toEqual({ listing_id: 'GME', base_id: '', quote_id: '', @@ -276,6 +278,7 @@ describe('Alpaca portfolio helpers', () => { const performance = await getAlpacaTradingAccountPerformance({ providerId: 'alpaca', + credentialId: 'oauth-credential-1', tokenAccountId: 'oauth-account-1', serviceId: 'alpaca-live', environment: 'live', @@ -294,6 +297,7 @@ describe('Alpaca portfolio helpers', () => { const performance = await getAlpacaTradingAccountPerformance({ providerId: 'alpaca', + credentialId: 'oauth-credential-1', tokenAccountId: 'oauth-account-1', serviceId: 'alpaca-live', environment: 'live', diff --git a/apps/tradinggoose/providers/trading/alpaca/positions.test.ts b/apps/tradinggoose/providers/trading/alpaca/positions.test.ts index 13b5f5e27..71705d7d4 100644 --- a/apps/tradinggoose/providers/trading/alpaca/positions.test.ts +++ b/apps/tradinggoose/providers/trading/alpaca/positions.test.ts @@ -11,7 +11,7 @@ describe('normalizeAlpacaPositions', () => { qty: '2', side: 'long', }, - ])[0]?.symbol.listing + ])[0]?.listingIdentity ).toEqual({ listing_id: 'AAPL', base_id: '', @@ -28,16 +28,11 @@ describe('normalizeAlpacaPositions', () => { }, ])[0] - expect(cryptoPosition?.symbol).toMatchObject({ - base: 'DOGE', - quote: 'USD', - assetClass: 'crypto', - listing: { - listing_id: '', - base_id: 'DOGE', - quote_id: 'USD', - listing_type: 'crypto', - }, + expect(cryptoPosition?.listingIdentity).toEqual({ + listing_id: '', + base_id: 'DOGE', + quote_id: 'USD', + listing_type: 'crypto', }) }) diff --git a/apps/tradinggoose/providers/trading/alpaca/positions.ts b/apps/tradinggoose/providers/trading/alpaca/positions.ts index e9aab450d..d09d0d258 100644 --- a/apps/tradinggoose/providers/trading/alpaca/positions.ts +++ b/apps/tradinggoose/providers/trading/alpaca/positions.ts @@ -1,11 +1,8 @@ -import { buildAlpacaAuthHeaders } from '@/providers/trading/alpaca/auth' -import { - alpacaTradingProviderConfig, -} from '@/providers/trading/alpaca/config' +import { alpacaTradingProviderConfig } from '@/providers/trading/alpaca/config' import { sumFiniteNumbers, toFiniteNumber } from '@/providers/trading/portfolio-utils' import type { UnifiedTradingPosition, - UnifiedTradingSymbol, + UnifiedTradingSymbolAssetClass, } from '@/providers/trading/types' import { tradingSymbolToListingIdentity } from '@/providers/trading/utils' @@ -20,9 +17,7 @@ export const mapAlpacaPositionSide = (value: unknown): UnifiedTradingPosition['s return 'unknown' } -export const mapAlpacaAssetClass = ( - value: unknown -): UnifiedTradingSymbol['assetClass'] | null => { +export const mapAlpacaAssetClass = (value: unknown): UnifiedTradingSymbolAssetClass | null => { switch (value) { case 'crypto': return 'crypto' @@ -60,9 +55,7 @@ export const normalizeAlpacaPositions = (positions: unknown): UnifiedTradingPosi assetClass, defaultQuote: ALPACA_DEFAULT_BASE_CURRENCY, }) - const base = resolvedSymbol?.base ?? 'UNKNOWN' const quote = resolvedSymbol?.quote ?? ALPACA_DEFAULT_BASE_CURRENCY - const symbolAssetClass = resolvedSymbol?.assetClass ?? assetClass const side = mapAlpacaPositionSide(position?.side) const rawQuantity = toFiniteNumber(position?.qty ?? position?.quantity) ?? 0 const quantity = side === 'short' ? -Math.abs(rawQuantity) : rawQuantity @@ -72,15 +65,7 @@ export const normalizeAlpacaPositions = (positions: unknown): UnifiedTradingPosi return [ { - symbol: { - base, - quote, - listing: resolvedSymbol?.listing, - name: null, - assetClass: symbolAssetClass, - active: true, - rank: 0, - }, + listingIdentity: resolvedSymbol?.listing ?? null, quantity, side, averagePrice: toFiniteNumber(position?.avg_entry_price), diff --git a/apps/tradinggoose/providers/trading/portfolio-detail.ts b/apps/tradinggoose/providers/trading/portfolio-detail.ts index d0b3d21fd..499504d87 100644 --- a/apps/tradinggoose/providers/trading/portfolio-detail.ts +++ b/apps/tradinggoose/providers/trading/portfolio-detail.ts @@ -1,3 +1,4 @@ +import { toListingValueObject } from '@/lib/listing/identity' import { resolveTradingListingIdentity } from '@/providers/trading/listing-resolution' import type { PortfolioDetail, @@ -14,15 +15,14 @@ import type { const resolvePortfolioPositions = async (positions: UnifiedTradingPosition[]) => Promise.all( positions.map(async (position) => { - const listing = await resolveTradingListingIdentity(position.symbol) - if (!listing) return position + const listingIdentity = toListingValueObject(position.listingIdentity) + const resolvedListingIdentity = listingIdentity + ? await resolveTradingListingIdentity({ listing: listingIdentity }) + : null return { ...position, - symbol: { - ...position.symbol, - listing, - }, + listingIdentity: resolvedListingIdentity ?? listingIdentity, } }) ) diff --git a/apps/tradinggoose/providers/trading/portfolio-identity.ts b/apps/tradinggoose/providers/trading/portfolio-identity.ts index 114378c4d..ac8a5c2cd 100644 --- a/apps/tradinggoose/providers/trading/portfolio-identity.ts +++ b/apps/tradinggoose/providers/trading/portfolio-identity.ts @@ -12,7 +12,7 @@ export type PortfolioEnvironment = 'live' | 'paper' export type PortfolioIdentity = { providerId: TradingProviderId - tokenAccountId: string + credentialId: string serviceId: string accountId: string providerName?: string | null @@ -44,17 +44,17 @@ export const toPortfolioValueObject = (value: unknown): PortfolioIdentity | null const record = value as Record const providerId = readText(record, 'providerId') - const tokenAccountId = readText(record, 'tokenAccountId') + const credentialId = readText(record, 'credentialId') const serviceId = readText(record, 'serviceId') const accountId = readText(record, 'accountId') - if (!providerId || !tokenAccountId || !serviceId || !accountId) { + if (!providerId || !credentialId || !serviceId || !accountId) { return null } const identity: PortfolioIdentity = { providerId: providerId as TradingProviderId, - tokenAccountId, + credentialId, serviceId, accountId, } @@ -75,7 +75,7 @@ export const toPortfolioValueObject = (value: unknown): PortfolioIdentity | null } export const getPortfolioIdentityKey = (portfolio: PortfolioIdentity) => - `${portfolio.providerId}|${portfolio.tokenAccountId}|${portfolio.serviceId}|${portfolio.accountId}` + `${portfolio.providerId}|${portfolio.credentialId}|${portfolio.serviceId}|${portfolio.accountId}` export const arePortfolioIdentitiesEqual = ( left?: PortfolioIdentity | null, diff --git a/apps/tradinggoose/providers/trading/portfolio-selectors.ts b/apps/tradinggoose/providers/trading/portfolio-selectors.ts index 462889bff..fcde75a62 100644 --- a/apps/tradinggoose/providers/trading/portfolio-selectors.ts +++ b/apps/tradinggoose/providers/trading/portfolio-selectors.ts @@ -24,7 +24,7 @@ export const getPortfolioListingExposures = ( >() for (const position of portfolioDetail.positions) { - const listing = toListingValueObject(position.symbol.listing) + const listing = toListingValueObject(position.listingIdentity) if (!listing) continue const key = getListingIdentityKey(listing) diff --git a/apps/tradinggoose/providers/trading/portfolio.test.ts b/apps/tradinggoose/providers/trading/portfolio.test.ts index 1d3069c35..1ccdebfa2 100644 --- a/apps/tradinggoose/providers/trading/portfolio.test.ts +++ b/apps/tradinggoose/providers/trading/portfolio.test.ts @@ -7,15 +7,15 @@ import { getTradingPortfolioSupportedWindows, isTradingPortfolioWindowSupported, } from '@/providers/trading/portfolio' -import { getTradingHoldingsCapabilities } from '@/providers/trading/providers' +import { getTradingPortfolioDetailCapabilities } from '@/providers/trading/providers' describe('Trading portfolio window contract', () => { it('reuses the provider definition supported window lists', () => { expect(getTradingPortfolioSupportedWindows('alpaca')).toEqual( - getTradingHoldingsCapabilities('alpaca')?.performanceWindows + getTradingPortfolioDetailCapabilities('alpaca')?.performanceWindows ) expect(getTradingPortfolioSupportedWindows('tradier')).toEqual( - getTradingHoldingsCapabilities('tradier')?.performanceWindows + getTradingPortfolioDetailCapabilities('tradier')?.performanceWindows ) }) diff --git a/apps/tradinggoose/providers/trading/portfolio.ts b/apps/tradinggoose/providers/trading/portfolio.ts index 646ce2e11..4073855a8 100644 --- a/apps/tradinggoose/providers/trading/portfolio.ts +++ b/apps/tradinggoose/providers/trading/portfolio.ts @@ -1,14 +1,11 @@ import { getAlpacaTradingAccounts } from '@/providers/trading/alpaca/accounts' import { getAlpacaTradingAccountPerformance } from '@/providers/trading/alpaca/performance' import { getAlpacaTradingAccountSnapshot } from '@/providers/trading/alpaca/snapshot' +import type { PortfolioDetail, PortfolioIdentity } from '@/providers/trading/portfolio-identity' +import { getTradingPortfolioDetailCapabilities } from '@/providers/trading/providers' import { getTradierTradingAccounts } from '@/providers/trading/tradier/accounts' import { getTradierTradingAccountPerformance } from '@/providers/trading/tradier/performance' import { getTradierTradingAccountSnapshot } from '@/providers/trading/tradier/snapshot' -import { getTradingHoldingsCapabilities } from '@/providers/trading/providers' -import type { - PortfolioDetail, - PortfolioIdentity, -} from '@/providers/trading/portfolio-identity' import type { TradingPortfolioAccountContext, TradingPortfolioBaseContext, @@ -20,7 +17,7 @@ import type { export const getTradingPortfolioSupportedWindows = ( providerId: TradingProviderId ): TradingPortfolioPerformanceWindow[] => { - return [...(getTradingHoldingsCapabilities(providerId)?.performanceWindows ?? [])] + return [...(getTradingPortfolioDetailCapabilities(providerId)?.performanceWindows ?? [])] } export const isTradingPortfolioWindowSupported = (providerId: TradingProviderId, window: string) => diff --git a/apps/tradinggoose/providers/trading/providers.ts b/apps/tradinggoose/providers/trading/providers.ts index 0f30ee321..2be6c79dd 100644 --- a/apps/tradinggoose/providers/trading/providers.ts +++ b/apps/tradinggoose/providers/trading/providers.ts @@ -24,7 +24,7 @@ import type { export interface TradingProviderAvailability { assetClass: AssetClass[] order: boolean - holdings: boolean + portfolioDetail: boolean availableListingQuote?: string[] availableCurrencyBase?: string[] availableCurrencyQuote?: string[] @@ -39,13 +39,13 @@ export interface TradingOrderInputCapabilities { preview?: boolean } -export interface TradingHoldingsInputCapabilities { +export interface TradingPortfolioDetailInputCapabilities { performanceWindows?: TradingPortfolioPerformanceWindow[] } export interface TradingProviderCapabilities { order?: TradingOrderInputCapabilities - holdings?: TradingHoldingsInputCapabilities + portfolioDetail?: TradingPortfolioDetailInputCapabilities } export type TradingOrderTypeRequirement = 'limitPrice' | 'stopPrice' | 'trailPrice' | 'trailPercent' @@ -178,10 +178,10 @@ export function getTradingProviderConfig( return TRADING_PROVIDER_DEFINITIONS[providerId]?.config || null } -export function getTradingHoldingsCapabilities( +export function getTradingPortfolioDetailCapabilities( providerId: TradingProviderId -): TradingHoldingsInputCapabilities | null { - return TRADING_PROVIDER_DEFINITIONS[providerId]?.config.capabilities?.holdings || null +): TradingPortfolioDetailInputCapabilities | null { + return TRADING_PROVIDER_DEFINITIONS[providerId]?.config.capabilities?.portfolioDetail || null } export function getTradingOrderCapabilities( @@ -243,7 +243,7 @@ export function getTradingProvidersByKind(kind: TradingOperationKind): TradingPr return Object.values(TRADING_PROVIDER_DEFINITIONS).filter((provider) => { const availability = provider.config.availability if (kind === 'order') return availability.order - return availability.holdings + return availability.portfolioDetail }) } diff --git a/apps/tradinggoose/providers/trading/tradier/accounts.ts b/apps/tradinggoose/providers/trading/tradier/accounts.ts index 9314efdc8..c79e42675 100644 --- a/apps/tradinggoose/providers/trading/tradier/accounts.ts +++ b/apps/tradinggoose/providers/trading/tradier/accounts.ts @@ -27,7 +27,7 @@ const toTradierAccountsArray = (profileResponse: any) => { export const normalizeTradierTradingAccount = ( account: any, - context: Pick + context: Pick ): PortfolioIdentity => { const accountNumber = typeof account?.account_number === 'string' ? account.account_number.trim() : '' @@ -40,7 +40,7 @@ export const normalizeTradierTradingAccount = ( return { providerId: context.providerId, - tokenAccountId: context.tokenAccountId, + credentialId: context.credentialId, serviceId: context.serviceId, accountId: accountNumber, providerName: 'Tradier', diff --git a/apps/tradinggoose/providers/trading/tradier/client.ts b/apps/tradinggoose/providers/trading/tradier/client.ts index 799ec715b..2767832a5 100644 --- a/apps/tradinggoose/providers/trading/tradier/client.ts +++ b/apps/tradinggoose/providers/trading/tradier/client.ts @@ -1,11 +1,11 @@ -import type { TradingHoldingsInput, TradingOrderInput } from '@/providers/trading/types' +import type { TradingOrderInput, TradingPortfolioDetailInput } from '@/providers/trading/types' const TRADIER_BASE_URL = 'https://api.tradier.com/v1' export const resolveTradierBaseUrl = () => TRADIER_BASE_URL export const buildTradierAuthHeaders = ( - params: TradingOrderInput | TradingHoldingsInput + params: TradingOrderInput | TradingPortfolioDetailInput ): Record => { if (!params.accessToken) { throw new Error('Tradier access token is required') diff --git a/apps/tradinggoose/providers/trading/tradier/config.ts b/apps/tradinggoose/providers/trading/tradier/config.ts index 046621411..e25ce8e1f 100644 --- a/apps/tradinggoose/providers/trading/tradier/config.ts +++ b/apps/tradinggoose/providers/trading/tradier/config.ts @@ -7,7 +7,7 @@ const availableAssetClasses: AssetClass[] = ['stock', 'etf'] const availability: TradingProviderConfig['availability'] = { assetClass: availableAssetClasses, order: true, - holdings: true, + portfolioDetail: true, } const exchangeCodeToMarketMap: TradingProviderConfig['exchangeCodeToMarket'] = {} @@ -44,7 +44,7 @@ export const tradierTradingProviderConfig: TradingProviderConfig = { ], timeInForce: ['day', 'gtc', 'pre', 'post'], }, - holdings: { + portfolioDetail: { performanceWindows: ['1W', '1M', 'YTD', '1Y', 'MAX'], }, }, diff --git a/apps/tradinggoose/providers/trading/tradier/orderDetail.ts b/apps/tradinggoose/providers/trading/tradier/orderDetail.ts index f9ffbeca8..85844ad91 100644 --- a/apps/tradinggoose/providers/trading/tradier/orderDetail.ts +++ b/apps/tradinggoose/providers/trading/tradier/orderDetail.ts @@ -1,11 +1,11 @@ import { fetchBrokerJson } from '@/providers/trading/portfolio-utils' import { buildTradierAuthHeaders, resolveTradierBaseUrl } from '@/providers/trading/tradier/client' import type { - TradingHoldingsInput, TradingOrderDetailInput, TradingOrderDetailOutput, TradingOrderDetailResult, TradingOrderHistoryRecord, + TradingPortfolioDetailInput, TradingRequestConfig, } from '@/providers/trading/types' @@ -49,7 +49,7 @@ export const buildTradierOrderDetailRequest = ( const baseUrl = resolveTradierBaseUrl() const authHeaders = buildTradierAuthHeaders({ accessToken: params.accessToken, - } as TradingHoldingsInput) + } as TradingPortfolioDetailInput) return { url: `${baseUrl}/accounts/${encodeURIComponent(accountId)}/orders/${encodeURIComponent(providerOrderId)}`, diff --git a/apps/tradinggoose/providers/trading/tradier/performance.ts b/apps/tradinggoose/providers/trading/tradier/performance.ts index 599a3a722..e3aaa5cb0 100644 --- a/apps/tradinggoose/providers/trading/tradier/performance.ts +++ b/apps/tradinggoose/providers/trading/tradier/performance.ts @@ -4,8 +4,8 @@ import { fetchBrokerJson, toFiniteNumber, } from '@/providers/trading/portfolio-utils' -import { tradierTradingProviderConfig } from '@/providers/trading/tradier/config' import { buildTradierAuthHeaders, resolveTradierBaseUrl } from '@/providers/trading/tradier/client' +import { tradierTradingProviderConfig } from '@/providers/trading/tradier/config' import type { TradingPortfolioAccountContext, TradingPortfolioPerformanceWindow, @@ -14,7 +14,7 @@ import type { } from '@/providers/trading/types' const getTradierSupportedPerformanceWindows = () => - tradierTradingProviderConfig.capabilities?.holdings?.performanceWindows ?? [] + tradierTradingProviderConfig.capabilities?.portfolioDetail?.performanceWindows ?? [] type TradierTradingPortfolioPerformanceWindow = Exclude< TradingPortfolioPerformanceWindow, diff --git a/apps/tradinggoose/providers/trading/tradier/portfolio.test.ts b/apps/tradinggoose/providers/trading/tradier/portfolio.test.ts index 7252ff357..535bc58a3 100644 --- a/apps/tradinggoose/providers/trading/tradier/portfolio.test.ts +++ b/apps/tradinggoose/providers/trading/tradier/portfolio.test.ts @@ -23,8 +23,8 @@ vi.mock('@/providers/trading/listing-resolution', () => ({ describe('Tradier portfolio helpers', () => { beforeEach(() => { vi.stubGlobal('fetch', vi.fn()) - resolveTradingListingIdentityMock.mockImplementation((symbol: { base: string }) => ({ - listing_id: symbol.base, + resolveTradingListingIdentityMock.mockImplementation((input: any) => ({ + listing_id: input?.listing?.listing_id ?? input?.listing?.base_id ?? '', base_id: '', quote_id: '', listing_type: 'default', @@ -47,13 +47,13 @@ describe('Tradier portfolio helpers', () => { }, { providerId: 'tradier', - tokenAccountId: 'oauth-account-1', + credentialId: 'oauth-credential-1', serviceId: 'tradier-live', } ) ).toEqual({ providerId: 'tradier', - tokenAccountId: 'oauth-account-1', + credentialId: 'oauth-credential-1', serviceId: 'tradier-live', accountId: 'ACC-123', providerName: 'Tradier', @@ -110,6 +110,7 @@ describe('Tradier portfolio helpers', () => { const snapshot = await getTradierTradingAccountSnapshot({ providerId: 'tradier', + credentialId: 'oauth-credential-1', tokenAccountId: 'oauth-account-1', serviceId: 'tradier-live', environment: 'live', @@ -127,7 +128,7 @@ describe('Tradier portfolio helpers', () => { totalUnrealizedPnl: 250, }) expect(snapshot.positions).toHaveLength(1) - expect(snapshot.positions[0]?.symbol.listing).toEqual({ + expect(snapshot.positions[0]?.listingIdentity).toEqual({ listing_id: 'MSFT', base_id: '', quote_id: '', @@ -167,6 +168,7 @@ describe('Tradier portfolio helpers', () => { it('returns an explicit unavailable payload for Tradier paper performance in v1', async () => { const performance = await getTradierTradingAccountPerformance({ providerId: 'tradier', + credentialId: 'oauth-credential-1', tokenAccountId: 'oauth-account-1', serviceId: 'tradier-live', environment: 'paper', @@ -185,6 +187,7 @@ describe('Tradier portfolio helpers', () => { it('returns an explicit unavailable payload for unsupported Tradier windows', async () => { const performance = await getTradierTradingAccountPerformance({ providerId: 'tradier', + credentialId: 'oauth-credential-1', tokenAccountId: 'oauth-account-1', serviceId: 'tradier-live', environment: 'live', diff --git a/apps/tradinggoose/providers/trading/tradier/positions.ts b/apps/tradinggoose/providers/trading/tradier/positions.ts index ac1448bc9..a7e884f62 100644 --- a/apps/tradinggoose/providers/trading/tradier/positions.ts +++ b/apps/tradinggoose/providers/trading/tradier/positions.ts @@ -1,9 +1,6 @@ import { sumFiniteNumbers, toFiniteNumber } from '@/providers/trading/portfolio-utils' import { tradierTradingProviderConfig } from '@/providers/trading/tradier/config' -import type { - UnifiedTradingAccountType, - UnifiedTradingPosition, -} from '@/providers/trading/types' +import type { UnifiedTradingAccountType, UnifiedTradingPosition } from '@/providers/trading/types' import { tradingSymbolToListingIdentity } from '@/providers/trading/utils' export const TRADIER_DEFAULT_BASE_CURRENCY = 'USD' @@ -87,15 +84,7 @@ export const normalizeTradierPositions = (positions: unknown): UnifiedTradingPos typeof position?.date_acquired === 'string' ? position.date_acquired : undefined return { - symbol: { - base: resolvedSymbol?.base ?? 'UNKNOWN', - quote: resolvedSymbol?.quote ?? TRADIER_DEFAULT_BASE_CURRENCY, - listing: resolvedSymbol?.listing, - name: null, - assetClass: resolvedSymbol?.assetClass ?? 'stock', - active: true, - rank: 0, - }, + listingIdentity: resolvedSymbol?.listing ?? null, quantity, side, averagePrice, diff --git a/apps/tradinggoose/providers/trading/types.ts b/apps/tradinggoose/providers/trading/types.ts index 9f06e3641..b8b88dac2 100644 --- a/apps/tradinggoose/providers/trading/types.ts +++ b/apps/tradinggoose/providers/trading/types.ts @@ -19,7 +19,7 @@ export interface TradingRequestConfig { body?: Record | string } -export type TradingOperationKind = 'order' | 'holdings' +export type TradingOperationKind = 'order' | 'portfolioDetail' export interface TradingSymbolInput { listing?: ListingInputValue @@ -49,13 +49,13 @@ export interface TradingOrderInput extends TradingSymbolInput { accountId?: string } -export interface TradingHoldingsInput { +export interface TradingPortfolioDetailInput { environment?: 'paper' | 'live' accessToken?: string accountId?: string } -export interface TradingOrderDetailInput extends TradingHoldingsInput { +export interface TradingOrderDetailInput extends TradingPortfolioDetailInput { orderId: string provider?: TradingProviderId } @@ -106,6 +106,7 @@ export interface TradingOrderDetailResult { export interface TradingPortfolioBaseContext { providerId: TradingProviderId + credentialId: string tokenAccountId: string serviceId: string environment?: 'paper' | 'live' @@ -156,7 +157,7 @@ export interface UnifiedTradingSymbol { export type UnifiedTradingPositionSide = 'long' | 'short' | 'flat' | 'unknown' export interface UnifiedTradingPosition { - symbol: UnifiedTradingSymbol + listingIdentity: ListingIdentity | null quantity: number side?: UnifiedTradingPositionSide averagePrice?: number @@ -288,12 +289,12 @@ export interface TradingActionResponse { error?: string } -export interface TradingHoldingsResponse { +export interface TradingPortfolioDetailResponse { success: boolean output: { summary: string provider: TradingProviderId - holdings: PortfolioDetail + portfolioDetail: PortfolioDetail } error?: string } diff --git a/apps/tradinggoose/socket-server/index.ts b/apps/tradinggoose/socket-server/index.ts index 9f394fc99..9b4dcf192 100644 --- a/apps/tradinggoose/socket-server/index.ts +++ b/apps/tradinggoose/socket-server/index.ts @@ -10,6 +10,7 @@ import { IndicatorMonitorRuntime } from '@/socket-server/market/indicator-monito import { type AuthenticatedSocket, authenticateSocket } from '@/socket-server/middleware/auth' import { createHttpHandler } from '@/socket-server/routes/http' import { tradingPortfolioStreamManager } from '@/socket-server/trading/portfolio-manager' +import { PortfolioMonitorRuntime } from '@/socket-server/trading/portfolio-monitor-runtime' import { isYjsUpgradeRequest, shieldNonYjsUpgradeListeners, @@ -40,14 +41,22 @@ const io = createSocketIOServer(httpServer) shieldNonYjsUpgradeListeners(httpServer, yjsUpgradeListener) const indicatorMonitorRuntime = new IndicatorMonitorRuntime(logger) +const portfolioMonitorRuntime = new PortfolioMonitorRuntime(logger) +const monitorRuntimes = { + indicator: indicatorMonitorRuntime, + portfolio: portfolioMonitorRuntime, +} io.use(authenticateSocket) const httpHandler = createHttpHandler(logger, { - getMonitorRuntimeHealth: () => indicatorMonitorRuntime.getHealth(), + getMonitorRuntimeHealth: () => + Object.fromEntries( + Object.entries(monitorRuntimes).map(([source, runtime]) => [source, runtime.getHealth()]) + ), getConnectionCount: () => yjsWss.clients.size + (io.engine?.clientsCount ?? 0), - onIndicatorMonitorsReconcile: async () => { - await indicatorMonitorRuntime.requestReconcile() + onMonitorsReconcile: async () => { + await Promise.all(Object.values(monitorRuntimes).map((runtime) => runtime.requestReconcile())) }, }) httpServer.on('request', httpHandler) @@ -118,8 +127,10 @@ logger.info('Starting Socket.IO server...', { httpServer.listen(PORT, '0.0.0.0', () => { logger.info(`Socket.IO server running on port ${PORT}`) logger.info(`🏥 Health check available at: http://localhost:${PORT}/health`) - void indicatorMonitorRuntime.start().catch((error) => { - logger.error('Failed to start indicator monitor runtime', { error }) + Object.entries(monitorRuntimes).forEach(([source, runtime]) => { + void runtime.start().catch((error) => { + logger.error(`Failed to start ${source} monitor runtime`, { error }) + }) }) }) @@ -135,10 +146,9 @@ const shutdown = () => { logger.info('Shutting down Socket.IO server...') tradingPortfolioStreamManager.stop() - void indicatorMonitorRuntime - .stop() + void Promise.all(Object.values(monitorRuntimes).map((runtime) => runtime.stop())) .catch((error) => { - logger.error('Failed to stop indicator monitor runtime cleanly', { error }) + logger.error('Failed to stop monitor runtimes cleanly', { error }) }) .finally(() => { void io.close((error) => { diff --git a/apps/tradinggoose/socket-server/market/indicator-monitor-runtime.test.ts b/apps/tradinggoose/socket-server/market/indicator-monitor-runtime.test.ts index 557922bd5..7fd796b69 100644 --- a/apps/tradinggoose/socket-server/market/indicator-monitor-runtime.test.ts +++ b/apps/tradinggoose/socket-server/market/indicator-monitor-runtime.test.ts @@ -57,10 +57,6 @@ vi.mock('@/lib/api-key/service', () => ({ getApiKeyOwnerUserId: vi.fn(), })) -vi.mock('@/lib/billing', () => ({ - checkServerSideUsageLimits: vi.fn(), -})) - vi.mock('@/lib/environment/utils', () => ({ getEffectiveDecryptedEnv: vi.fn(), })) @@ -87,20 +83,12 @@ vi.mock('@/lib/indicators/input-meta', () => ({ normalizeInputMetaMap: vi.fn(() => ({})), })) -vi.mock('@/lib/indicators/monitor-config', () => ({ - INDICATOR_MONITOR_TRIGGER_ID: 'indicator-monitor', -})) - vi.mock('@/lib/indicators/series-data', () => ({ mapMarketBarToBarMs: vi.fn(), mapMarketSeriesToBarsMs: vi.fn(() => []), normalizeBarsMs: vi.fn(() => []), })) -vi.mock('@/lib/indicators/trigger-detection', () => ({ - isIndicatorTriggerCapable: vi.fn(() => true), -})) - vi.mock('@/lib/listing/identity', () => ({ toListingValueObject: vi.fn(), })) @@ -113,28 +101,16 @@ vi.mock('@/lib/redis', () => ({ getRedisStorageMode: getRedisStorageModeMock, })) -vi.mock('@/lib/trigger/settings', () => ({ - TriggerExecutionUnavailableError: class TriggerExecutionUnavailableError extends Error {}, -})) - vi.mock('@/lib/utils-server', () => ({ decryptSecret: vi.fn(), })) -vi.mock('@/lib/workflows/db-helpers', () => ({ - blockExistsInDeployment: vi.fn(() => true), -})) - vi.mock('@/providers/market', () => ({ executeProviderRequest: vi.fn(), })) -vi.mock('@/providers/market/alpaca/config', () => ({ - alpacaProviderConfig: {}, -})) - -vi.mock('@/providers/market/finnhub/config', () => ({ - finnhubProviderConfig: {}, +vi.mock('@/providers/market/providers', () => ({ + getMarketProviderConfig: vi.fn(() => ({})), })) vi.mock('@/providers/market/utils', () => ({ @@ -190,7 +166,7 @@ describe('IndicatorMonitorRuntime lock lifecycle', () => { expect(acquireLockMock).toHaveBeenCalledWith( 'indicator-monitor-runtime-lock', expect.any(String), - 90, + 90 ) expect(runtime.getHealth().status).toBe('running') @@ -199,7 +175,7 @@ describe('IndicatorMonitorRuntime lock lifecycle', () => { expect(renewLockMock).toHaveBeenCalledWith( 'indicator-monitor-runtime-lock', expect.any(String), - 90, + 90 ) await runtime.stop() @@ -226,7 +202,7 @@ describe('IndicatorMonitorRuntime lock lifecycle', () => { 'Indicator monitor paused; runtime unavailable', expect.objectContaining({ reason: 'lock', - }), + }) ) await runtime.stop() @@ -244,7 +220,7 @@ describe('IndicatorMonitorRuntime lock lifecycle', () => { expect(releaseLockMock).toHaveBeenCalledWith( 'indicator-monitor-runtime-lock', - expect.any(String), + expect.any(String) ) const renewCallCount = renewLockMock.mock.calls.length diff --git a/apps/tradinggoose/socket-server/market/indicator-monitor-runtime.ts b/apps/tradinggoose/socket-server/market/indicator-monitor-runtime.ts index e495415c7..562c908d9 100644 --- a/apps/tradinggoose/socket-server/market/indicator-monitor-runtime.ts +++ b/apps/tradinggoose/socket-server/market/indicator-monitor-runtime.ts @@ -3,73 +3,48 @@ import { db } from '@tradinggoose/db' import { pineIndicators, webhook, workflow } from '@tradinggoose/db/schema' import { and, eq, inArray } from 'drizzle-orm' import { getApiKeyOwnerUserId } from '@/lib/api-key/service' -import { checkServerSideUsageLimits } from '@/lib/billing' import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' -import { - ExecutionGateError, -} from '@/lib/execution/execution-concurrency-limit' +import { ExecutionGateError } from '@/lib/execution/execution-concurrency-limit' import { enqueuePendingExecution, isPendingExecutionLimitError, } from '@/lib/execution/pending-execution' import { DEFAULT_INDICATOR_RUNTIME_MAP } from '@/lib/indicators/default/runtime' import { resolveDispatchIntervalMs } from '@/lib/indicators/dispatch' -import { - buildInputsMapFromMeta, - normalizeInputMetaMap, -} from '@/lib/indicators/input-meta' -import { INDICATOR_MONITOR_TRIGGER_ID } from '@/lib/indicators/monitor-config' +import { buildInputsMapFromMeta, normalizeInputMetaMap } from '@/lib/indicators/input-meta' import { mapMarketBarToBarMs, mapMarketSeriesToBarsMs, normalizeBarsMs, } from '@/lib/indicators/series-data' -import { isIndicatorTriggerCapable } from '@/lib/indicators/trigger-detection' import type { BarMs } from '@/lib/indicators/types' -import { - type ListingIdentity, - toListingValueObject, -} from '@/lib/listing/identity' +import { type ListingIdentity, toListingValueObject } from '@/lib/listing/identity' import { createLogger } from '@/lib/logs/console/logger' import { - acquireLock, - getRedisClient, - getRedisStorageMode, - renewLock, - releaseLock, -} from '@/lib/redis' -import { TriggerExecutionUnavailableError } from '@/lib/trigger/settings' + INDICATOR_MONITOR_PROVIDER, + isMonitorProviderConfigForProvider, +} from '@/lib/monitors/sources' import { decryptSecret } from '@/lib/utils-server' -import { blockExistsInDeployment } from '@/lib/workflows/db-helpers' import { applySavedEntityYjsStateToRows } from '@/lib/yjs/entity-state' +import type { MonitorExecutionPayload } from '@/background/monitor-execution' import { executeProviderRequest } from '@/providers/market' -import { alpacaProviderConfig } from '@/providers/market/alpaca/config' -import { finnhubProviderConfig } from '@/providers/market/finnhub/config' +import { getMarketProviderConfig } from '@/providers/market/providers' import type { MarketBar, MarketSeries } from '@/providers/market/types' -import { - resolveListingContext, - resolveProviderSymbol, -} from '@/providers/market/utils' -import type { IndicatorMonitorExecutionPayload } from '@/background/indicator-monitor-execution' -import { marketStreamManager } from '@/socket-server/market/manager' +import { resolveListingContext, resolveProviderSymbol } from '@/providers/market/utils' +import { type AnyMarketProviderId, marketStreamManager } from '@/socket-server/market/manager' import type { AuthenticatedSocket } from '@/socket-server/middleware/auth' - -type MonitorRuntimeStatus = - | 'not_initialized' - | 'running' - | 'degraded' - | 'disabled' +import { + createMonitorRuntimeLock, + getMonitorRuntimeUnavailableStatus, + isMonitorRuntimeDatabaseConnectionError, + type MonitorRuntimeLockHealth, + type MonitorRuntimeStatus, +} from '@/socket-server/monitor-runtime-lock' export type IndicatorMonitorRuntimeHealth = { enabled: boolean status: MonitorRuntimeStatus - reconcileEndpointEnabled: boolean - lock: { - mode: 'fail_closed' - redisConfigured: boolean - redisClientAvailable: boolean - degraded: boolean - } + lock: MonitorRuntimeLockHealth stats: { activeSubscriptions: number lastReconcileAt: string | null @@ -100,7 +75,7 @@ type MonitorRuntimeConfig = { userId: string pinnedApiKeyId: string | null blockId: string - providerId: 'alpaca' | 'finnhub' + providerId: AnyMarketProviderId interval: string intervalMs: number | null indicatorId: string @@ -129,37 +104,9 @@ type IndicatorMonitorSubscription = { const logger = createLogger('IndicatorMonitorRuntime') const LOCK_KEY = 'indicator-monitor-runtime-lock' -const LOCK_EXPIRY_SECONDS = 90 -const LOCK_REFRESH_INTERVAL_MS = 30_000 const RECONCILE_INTERVAL_MS = 30_000 const MONITOR_WINDOW_BARS = 2000 const ENV_VAR_PATTERN = /\{\{([^}]+)\}\}/g -const DATABASE_CONNECTION_ERROR_CODES = new Set([ - 'ECONNREFUSED', - 'ECONNRESET', - 'ETIMEDOUT', - 'ENOTFOUND', - 'EHOSTUNREACH', -]) - -function isDatabaseConnectionError(error: unknown): boolean { - const seen = new Set() - let current: unknown = error - - while (current && typeof current === 'object' && !seen.has(current)) { - seen.add(current) - - const code = (current as { code?: unknown }).code - if (typeof code === 'string' && DATABASE_CONNECTION_ERROR_CODES.has(code)) { - return true - } - - current = (current as { cause?: unknown }).cause - } - - return false -} - const toTrimmedString = (value: unknown): string | null => { if (typeof value !== 'string') return null const trimmed = value.trim() @@ -175,31 +122,21 @@ const normalizeProviderConfig = ( row: typeof webhook.$inferSelect, workspaceId: string, userId: string, - pinnedApiKeyId: string | null, + pinnedApiKeyId: string | null ): MonitorRuntimeConfig | null => { if (!isRecord(row.providerConfig)) return null const providerConfig = row.providerConfig - const triggerId = toTrimmedString(providerConfig.triggerId) - if (triggerId && triggerId !== INDICATOR_MONITOR_TRIGGER_ID) return null + if (!isMonitorProviderConfigForProvider(providerConfig, INDICATOR_MONITOR_PROVIDER)) return null - if (providerConfig.monitor !== undefined && !isRecord(providerConfig.monitor)) - return null - - const monitor = isRecord(providerConfig.monitor) - ? providerConfig.monitor - : providerConfig + const monitor = providerConfig.monitor const providerId = toTrimmedString(monitor.providerId) const interval = toTrimmedString(monitor.interval) const indicatorId = toTrimmedString(monitor.indicatorId) const listing = toListingValueObject(monitor.listing as any) - const triggerBlockId = - toTrimmedString(monitor.triggerBlockId) ?? - toTrimmedString(monitor.blockId) ?? - toTrimmedString(row.blockId) + const triggerBlockId = toTrimmedString(monitor.triggerBlockId) - if (!providerId || (providerId !== 'alpaca' && providerId !== 'finnhub')) - return null + if (!providerId || !getMarketProviderConfig(providerId)) return null if (!interval || !indicatorId || !listing) return null if (!triggerBlockId) return null @@ -226,7 +163,7 @@ const normalizeProviderConfig = ( userId, pinnedApiKeyId, blockId: triggerBlockId, - providerId, + providerId: providerId as AnyMarketProviderId, interval, intervalMs, indicatorId, @@ -240,15 +177,13 @@ const normalizeProviderConfig = ( ...normalized, signature: JSON.stringify({ ...normalized, - auth: normalized.auth - ? { hasSecrets: Boolean(normalized.auth.encryptedSecrets) } - : undefined, + auth: normalized.auth ? { hasSecrets: Boolean(normalized.auth.encryptedSecrets) } : undefined, }), } } export async function resolveMonitorAuth( - monitor: MonitorRuntimeConfig, + monitor: MonitorRuntimeConfig ): Promise<{ apiKey?: string; apiSecret?: string }> { const encryptedSecrets = monitor.auth?.encryptedSecrets ?? {} const decryptedSecrets: Record = {} @@ -263,25 +198,19 @@ export async function resolveMonitorAuth( if (decrypted.includes('{{') && decrypted.includes('}}')) { if (!envVars) { - envVars = await getEffectiveDecryptedEnv( - monitor.userId, - monitor.workspaceId, - ) + envVars = await getEffectiveDecryptedEnv(monitor.userId, monitor.workspaceId) } - const resolved = decrypted.replace( - ENV_VAR_PATTERN, - (_match, envKeyRaw) => { - const envKey = String(envKeyRaw).trim() - if (!envKey) return _match - const envValue = envVars?.[envKey] - if (envValue === undefined) { - missingVars.add(envKey) - return '' - } - return envValue - }, - ) + const resolved = decrypted.replace(ENV_VAR_PATTERN, (_match, envKeyRaw) => { + const envKey = String(envKeyRaw).trim() + if (!envKey) return _match + const envValue = envVars?.[envKey] + if (envValue === undefined) { + missingVars.add(envKey) + return '' + } + return envValue + }) const trimmedResolved = resolved.trim() if (trimmedResolved) { decryptedSecrets[key] = trimmedResolved @@ -301,7 +230,7 @@ export async function resolveMonitorAuth( if (missingVars.size > 0) { throw new Error( - `Missing environment variable${missingVars.size > 1 ? 's' : ''}: ${Array.from(missingVars).join(', ')}`, + `Missing environment variable${missingVars.size > 1 ? 's' : ''}: ${Array.from(missingVars).join(', ')}` ) } @@ -312,36 +241,28 @@ export async function resolveMonitorAuth( } async function resolveIndicatorDefinitions( - monitors: MonitorRuntimeConfig[], + monitors: MonitorRuntimeConfig[] ): Promise> { const definitions = new Map() monitors.forEach((monitor) => { - const defaultIndicator = DEFAULT_INDICATOR_RUNTIME_MAP.get( - monitor.indicatorId, - ) + const defaultIndicator = DEFAULT_INDICATOR_RUNTIME_MAP.get(monitor.indicatorId) if (!defaultIndicator) return definitions.set(`${monitor.workspaceId}:${monitor.indicatorId}`, { id: monitor.indicatorId, name: defaultIndicator.name, pineCode: defaultIndicator.pineCode, - inputMeta: defaultIndicator.inputMeta as - | Record - | undefined, + inputMeta: defaultIndicator.inputMeta as Record | undefined, }) }) const unresolvedCustoms = monitors.filter( - (monitor) => !DEFAULT_INDICATOR_RUNTIME_MAP.has(monitor.indicatorId), + (monitor) => !DEFAULT_INDICATOR_RUNTIME_MAP.has(monitor.indicatorId) ) if (unresolvedCustoms.length === 0) return definitions - const indicatorIds = Array.from( - new Set(unresolvedCustoms.map((monitor) => monitor.indicatorId)), - ) - const workspaceIds = Array.from( - new Set(unresolvedCustoms.map((monitor) => monitor.workspaceId)), - ) + const indicatorIds = Array.from(new Set(unresolvedCustoms.map((monitor) => monitor.indicatorId))) + const workspaceIds = Array.from(new Set(unresolvedCustoms.map((monitor) => monitor.workspaceId))) const rows = await db .select({ @@ -355,8 +276,8 @@ async function resolveIndicatorDefinitions( .where( and( inArray(pineIndicators.id, indicatorIds), - inArray(pineIndicators.workspaceId, workspaceIds), - ), + inArray(pineIndicators.workspaceId, workspaceIds) + ) ) const indicators = await applySavedEntityYjsStateToRows('indicator', rows) @@ -366,8 +287,7 @@ async function resolveIndicatorDefinitions( id: row.id, name: row.name, pineCode: row.pineCode, - inputMeta: - (row.inputMeta as Record | undefined) ?? undefined, + inputMeta: (row.inputMeta as Record | undefined) ?? undefined, }) }) @@ -376,12 +296,10 @@ async function resolveIndicatorDefinitions( export class IndicatorMonitorRuntime { private readonly logger: LoggerLike + private readonly runtimeLock: ReturnType private status: MonitorRuntimeStatus = 'not_initialized' private running = false private starting = false - private lockHeld = false - private instanceId = randomUUID() - private lockRefreshTimer: ReturnType | null = null private reconcileTimer: ReturnType | null = null private retryTimer: ReturnType | null = null private isReconciling = false @@ -394,24 +312,19 @@ export class IndicatorMonitorRuntime { constructor(loggerLike?: LoggerLike) { this.logger = loggerLike ?? logger + this.runtimeLock = createMonitorRuntimeLock({ + key: LOCK_KEY, + label: 'Indicator monitor', + logger: this.logger, + onLost: (error) => this.enterDegradedState('lock', error, true), + }) } getHealth(): IndicatorMonitorRuntimeHealth { - const redisConfigured = getRedisStorageMode() === 'redis' - const redisClientAvailable = Boolean(getRedisClient()) - const degraded = - this.status === 'degraded' || (redisConfigured && !redisClientAvailable) - return { enabled: this.running, status: this.status, - reconcileEndpointEnabled: true, - lock: { - mode: 'fail_closed', - redisConfigured, - redisClientAvailable, - degraded, - }, + lock: this.runtimeLock.getHealth(this.status), stats: { activeSubscriptions: this.subscriptions.size, lastReconcileAt: this.lastReconcileAt, @@ -434,12 +347,6 @@ export class IndicatorMonitorRuntime { this.reconcileTimer = null } - private clearLockRefreshTimer() { - if (!this.lockRefreshTimer) return - clearInterval(this.lockRefreshTimer) - this.lockRefreshTimer = null - } - private stopSubscriptions() { const subscriptions = Array.from(this.subscriptions.values()) subscriptions.forEach((subscription) => { @@ -452,52 +359,6 @@ export class IndicatorMonitorRuntime { this.subscriptions.clear() } - private async releaseLockIfHeld() { - if (!this.lockHeld) return - - try { - await releaseLock(LOCK_KEY, this.instanceId) - } catch (error) { - this.logger.warn('Failed to release indicator monitor runtime lock', { - error, - }) - } finally { - this.lockHeld = false - } - } - - private startLockRefreshTimer() { - if (this.lockRefreshTimer) return - - this.lockRefreshTimer = setInterval(() => { - void this.refreshLock() - }, LOCK_REFRESH_INTERVAL_MS) - this.lockRefreshTimer.unref?.() - } - - private async refreshLock() { - if (!this.running || !this.lockHeld) return - - try { - const renewed = await renewLock( - LOCK_KEY, - this.instanceId, - LOCK_EXPIRY_SECONDS, - ) - if (renewed) return - - this.lockHeld = false - await this.enterDegradedState( - 'lock', - new Error('Indicator monitor runtime lock ownership was lost'), - true, - ) - } catch (error) { - this.lockHeld = false - await this.enterDegradedState('lock', error, true) - } - } - private scheduleRetry() { if (this.retryTimer) return @@ -511,17 +372,16 @@ export class IndicatorMonitorRuntime { private async enterDegradedState( reason: 'startup' | 'interval' | 'request' | 'lock', error: unknown, - shouldLogWarning: boolean, + shouldLogWarning: boolean ) { - this.lastReconcileError = - error instanceof Error ? error.message : String(error) + this.lastReconcileError = error instanceof Error ? error.message : String(error) this.status = 'degraded' this.running = false this.pendingReconcile = false - this.clearLockRefreshTimer() + this.runtimeLock.stopRenewal() this.clearReconcileTimer() this.stopSubscriptions() - await this.releaseLockIfHeld() + await this.runtimeLock.release() this.scheduleRetry() if (shouldLogWarning) { @@ -546,37 +406,19 @@ export class IndicatorMonitorRuntime { try { this.clearRetryTimer() - let lockAcquired = false - try { - lockAcquired = await acquireLock( - LOCK_KEY, - this.instanceId, - LOCK_EXPIRY_SECONDS, - ) - } catch (error) { - this.logger.warn('Indicator monitor runtime lock acquisition error', { - error, - }) - } - - if (!lockAcquired) { + if (!(await this.runtimeLock.acquire())) { this.running = false - this.lockHeld = false - this.status = - getRedisStorageMode() === 'redis' ? 'degraded' : 'disabled' - this.logger.warn( - 'Indicator monitor runtime disabled; lock acquisition failed.', - ) + this.status = getMonitorRuntimeUnavailableStatus() + this.logger.warn('Indicator monitor runtime disabled; lock acquisition failed.') this.scheduleRetry() return } - this.lockHeld = true this.running = true this.status = 'running' - this.clearLockRefreshTimer() + this.runtimeLock.stopRenewal() this.clearReconcileTimer() - this.startLockRefreshTimer() + this.runtimeLock.startRenewal() await this.reconcile('startup') @@ -595,11 +437,11 @@ export class IndicatorMonitorRuntime { async stop() { this.clearRetryTimer() - this.clearLockRefreshTimer() + this.runtimeLock.stopRenewal() this.clearReconcileTimer() this.stopSubscriptions() - await this.releaseLockIfHeld() + await this.runtimeLock.release() this.running = false this.starting = false @@ -639,9 +481,7 @@ export class IndicatorMonitorRuntime { }) .from(webhook) .innerJoin(workflow, eq(webhook.workflowId, workflow.id)) - .where( - and(eq(webhook.provider, 'indicator'), eq(webhook.isActive, true)), - ) + .where(and(eq(webhook.provider, INDICATOR_MONITOR_PROVIDER), eq(webhook.isActive, true))) let skippedMissingWorkspace = 0 let skippedInvalidConfig = 0 @@ -657,13 +497,9 @@ export class IndicatorMonitorRuntime { if (!row.workflow.isDeployed) { disconnectedInvalidWorkflow += 1 - await this.disconnectMonitor( - row.webhook.id, - 'workflow_not_deployed', - { - workflowId: row.workflow.id, - }, - ) + await this.disconnectMonitor(row.webhook.id, 'workflow_not_deployed', { + workflowId: row.workflow.id, + }) continue } @@ -671,18 +507,14 @@ export class IndicatorMonitorRuntime { row.webhook, row.workflow.workspaceId, row.workflow.userId, - row.workflow.pinnedApiKeyId, + row.workflow.pinnedApiKeyId ) if (!normalized) { skippedInvalidConfig += 1 - await this.disconnectMonitor( - row.webhook.id, - 'invalid_monitor_config', - { - workflowId: row.workflow.id, - }, - ) + await this.disconnectMonitor(row.webhook.id, 'invalid_monitor_config', { + workflowId: row.workflow.id, + }) continue } @@ -698,26 +530,24 @@ export class IndicatorMonitorRuntime { skippedMissingWorkspace, skippedInvalidConfig, disconnectedInvalidWorkflow, - }, + } ) } const indicatorDefinitions = await resolveIndicatorDefinitions(monitors) const nextMonitorIds = new Set(monitors.map((monitor) => monitor.id)) - Array.from(this.subscriptions.entries()).forEach( - ([monitorId, subscription]) => { - if (!nextMonitorIds.has(monitorId)) { - this.stopSubscription(subscription) - this.subscriptions.delete(monitorId) - } - }, - ) + Array.from(this.subscriptions.entries()).forEach(([monitorId, subscription]) => { + if (!nextMonitorIds.has(monitorId)) { + this.stopSubscription(subscription) + this.subscriptions.delete(monitorId) + } + }) for (const monitor of monitors) { const existing = this.subscriptions.get(monitor.id) const nextIndicator = indicatorDefinitions.get( - `${monitor.workspaceId}:${monitor.indicatorId}`, + `${monitor.workspaceId}:${monitor.indicatorId}` ) if (!nextIndicator) { await this.disconnectMonitor(monitor.id, 'indicator_not_found', { @@ -729,32 +559,6 @@ export class IndicatorMonitorRuntime { continue } - if (!isIndicatorTriggerCapable(nextIndicator.pineCode)) { - await this.disconnectMonitor( - monitor.id, - 'indicator_not_trigger_capable', - { - monitorId: monitor.id, - workspaceId: monitor.workspaceId, - indicatorId: monitor.indicatorId, - }, - ) - this.skippedCount += 1 - continue - } - - if ( - !(await blockExistsInDeployment(monitor.workflowId, monitor.blockId)) - ) { - await this.disconnectMonitor(monitor.id, 'missing_trigger_block', { - monitorId: monitor.id, - workflowId: monitor.workflowId, - blockId: monitor.blockId, - }) - this.skippedCount += 1 - continue - } - const actorUserId = await getApiKeyOwnerUserId(monitor.pinnedApiKeyId) if (!actorUserId) { await this.disconnectMonitor(monitor.id, 'missing_billing_actor', { @@ -765,22 +569,6 @@ export class IndicatorMonitorRuntime { continue } - const usageCheck = await checkServerSideUsageLimits({ - userId: actorUserId, - workflowId: monitor.workflowId, - workspaceId: monitor.workspaceId, - }) - if (usageCheck.isExceeded) { - await this.disconnectMonitor(monitor.id, 'usage_limit_exceeded', { - monitorId: monitor.id, - workflowId: monitor.workflowId, - currentUsage: usageCheck.currentUsage, - limit: usageCheck.limit, - }) - this.skippedCount += 1 - continue - } - if (existing && existing.config.signature === monitor.signature) { continue } @@ -791,10 +579,7 @@ export class IndicatorMonitorRuntime { } try { - const subscription = await this.createSubscription( - monitor, - nextIndicator, - ) + const subscription = await this.createSubscription(monitor, nextIndicator) this.subscriptions.set(monitor.id, subscription) } catch (error) { this.logger.warn('Failed to start indicator monitor subscription', { @@ -820,15 +605,10 @@ export class IndicatorMonitorRuntime { activeSubscriptions: this.subscriptions.size, }) } catch (error) { - this.lastReconcileError = - error instanceof Error ? error.message : String(error) - - if (isDatabaseConnectionError(error)) { - await this.enterDegradedState( - reason, - error, - this.subscriptions.size > 0, - ) + this.lastReconcileError = error instanceof Error ? error.message : String(error) + + if (isMonitorRuntimeDatabaseConnectionError(error)) { + await this.enterDegradedState(reason, error, this.subscriptions.size > 0) return } @@ -847,17 +627,15 @@ export class IndicatorMonitorRuntime { private async createSubscription( monitor: MonitorRuntimeConfig, - indicator: IndicatorDefinition, + indicator: IndicatorDefinition ): Promise { const auth = await resolveMonitorAuth(monitor) const listingContext = await resolveListingContext(monitor.listing) - const providerConfig = - monitor.providerId === 'alpaca' - ? alpacaProviderConfig - : finnhubProviderConfig - const symbol = normalizeSymbol( - resolveProviderSymbol(providerConfig, listingContext), - ) + const providerConfig = getMarketProviderConfig(monitor.providerId) + if (!providerConfig) { + throw new Error(`Market provider not found: ${monitor.providerId}`) + } + const symbol = normalizeSymbol(resolveProviderSymbol(providerConfig, listingContext)) if (!symbol) { throw new Error('Unable to resolve provider symbol') @@ -880,9 +658,7 @@ export class IndicatorMonitorRuntime { symbol, marketCode: listingContext.marketCode, timezone: listingContext.timeZoneName ?? undefined, - startAt: cappedBars[0] - ? new Date(cappedBars[0].openTime).toISOString() - : undefined, + startAt: cappedBars[0] ? new Date(cappedBars[0].openTime).toISOString() : undefined, endAt: cappedBars[cappedBars.length - 1] ? new Date(cappedBars[cappedBars.length - 1].openTime).toISOString() : undefined, @@ -891,7 +667,7 @@ export class IndicatorMonitorRuntime { private async createManagedMarketStream( monitor: MonitorRuntimeConfig, - auth: { apiKey?: string; apiSecret?: string }, + auth: { apiKey?: string; apiSecret?: string } ) { const syntheticSocket = { id: `indicator-monitor-runtime:${monitor.id}`, @@ -909,13 +685,11 @@ export class IndicatorMonitorRuntime { typeof payload?.message === 'string' && payload.message.trim() ? payload.message : 'Market stream error' - this.logger.warn( - `${monitor.providerId === 'alpaca' ? 'Alpaca' : 'Finnhub'} monitor stream error`, - { - monitorId: monitor.id, - message, - }, - ) + this.logger.warn('Indicator monitor market stream error', { + monitorId: monitor.id, + providerId: monitor.providerId, + message, + }) } }, } as unknown as AuthenticatedSocket @@ -947,7 +721,7 @@ export class IndicatorMonitorRuntime { private async fetchMonitorBars( monitor: MonitorRuntimeConfig, - auth: { apiKey?: string; apiSecret?: string }, + auth: { apiKey?: string; apiSecret?: string } ): Promise { const result = await executeProviderRequest(monitor.providerId, { kind: 'series', @@ -964,7 +738,7 @@ export class IndicatorMonitorRuntime { const marketSeries = result as MarketSeries return normalizeBarsMs( mapMarketSeriesToBarsMs(marketSeries, monitor.intervalMs ?? undefined), - monitor.intervalMs ?? undefined, + monitor.intervalMs ?? undefined ) } @@ -979,7 +753,7 @@ export class IndicatorMonitorRuntime { private async disconnectMonitor( monitorId: string, reason: string, - metadata: Record = {}, + metadata: Record = {} ) { const subscription = this.subscriptions.get(monitorId) if (subscription) { @@ -993,7 +767,7 @@ export class IndicatorMonitorRuntime { isActive: false, updatedAt: new Date(), }) - .where(and(eq(webhook.id, monitorId), eq(webhook.provider, 'indicator'))) + .where(and(eq(webhook.id, monitorId), eq(webhook.provider, INDICATOR_MONITOR_PROVIDER))) this.logger.warn('Indicator monitor disconnected', { monitorId, @@ -1006,15 +780,12 @@ export class IndicatorMonitorRuntime { const subscription = this.subscriptions.get(monitorId) if (!subscription) return - const mapped = mapMarketBarToBarMs( - bar, - subscription.config.intervalMs ?? undefined, - ) + const mapped = mapMarketBarToBarMs(bar, subscription.config.intervalMs ?? undefined) if (!mapped) return const mergedBars = normalizeBarsMs( [...subscription.bars, mapped], - subscription.config.intervalMs ?? undefined, + subscription.config.intervalMs ?? undefined ) const cappedBars = mergedBars.slice(-MONITOR_WINDOW_BARS) subscription.bars = cappedBars @@ -1028,9 +799,7 @@ export class IndicatorMonitorRuntime { await this.enqueueMonitorExecution(subscription) } - private async enqueueMonitorExecution( - subscription: IndicatorMonitorSubscription, - ) { + private async enqueueMonitorExecution(subscription: IndicatorMonitorSubscription) { const monitor = subscription.config try { @@ -1044,8 +813,9 @@ export class IndicatorMonitorRuntime { return } - const pendingExecutionId = `indicator_monitor:${monitor.id}:${randomUUID()}` - const payload: IndicatorMonitorExecutionPayload = { + const pendingExecutionId = `monitor:${monitor.id}:${randomUUID()}` + const payload: MonitorExecutionPayload = { + source: INDICATOR_MONITOR_PROVIDER, monitor: { id: monitor.id, workflowId: monitor.workflowId, @@ -1072,13 +842,12 @@ export class IndicatorMonitorRuntime { try { const handle = await enqueuePendingExecution({ - executionType: 'indicator_monitor', + executionType: 'monitor', pendingExecutionId, workflowId: monitor.workflowId, workspaceId: monitor.workspaceId, userId: actorUserId, - source: 'indicator_monitor', - orderingKey: `indicator_monitor:${monitor.id}`, + source: 'monitor:indicator:calculation', requestId: pendingExecutionId, payload, }) @@ -1097,30 +866,17 @@ export class IndicatorMonitorRuntime { return } - if (error instanceof TriggerExecutionUnavailableError) { - await this.disconnectMonitor(monitor.id, 'trigger_execution_disabled', { + if (isPendingExecutionLimitError(error)) { + this.logger.warn('Indicator monitor queue backlog is full; skipping monitor event', { monitorId: monitor.id, workflowId: monitor.workflowId, - error: error.message, + pendingCount: error.details.pendingCount, + maxPendingCount: error.details.maxPendingCount, }) this.skippedCount += 1 return } - if (isPendingExecutionLimitError(error)) { - this.logger.warn( - 'Indicator monitor queue backlog is full; skipping monitor event', - { - monitorId: monitor.id, - workflowId: monitor.workflowId, - pendingCount: error.details.pendingCount, - maxPendingCount: error.details.maxPendingCount, - }, - ) - this.skippedCount += 1 - return - } - throw error } diff --git a/apps/tradinggoose/socket-server/market/manager.test.ts b/apps/tradinggoose/socket-server/market/manager.test.ts index d2dae5e5f..78b87ce63 100644 --- a/apps/tradinggoose/socket-server/market/manager.test.ts +++ b/apps/tradinggoose/socket-server/market/manager.test.ts @@ -9,16 +9,18 @@ const { getEffectiveDecryptedEnvMock } = vi.hoisted(() => ({ const { buildMarketQuoteSnapshotMock, - getMarketLiveCapabilitiesMock, + executeProviderRequestMock, getMarketProviderConfigMock, + getMarketProviderPollingIntervalMsMock, resolveListingContextMock, resolveProviderSymbolMock, alpacaStreamInstances, finnhubStreamInstances, } = vi.hoisted(() => ({ buildMarketQuoteSnapshotMock: vi.fn(), - getMarketLiveCapabilitiesMock: vi.fn(), + executeProviderRequestMock: vi.fn(), getMarketProviderConfigMock: vi.fn(), + getMarketProviderPollingIntervalMsMock: vi.fn(), resolveListingContextMock: vi.fn(), resolveProviderSymbolMock: vi.fn(), alpacaStreamInstances: [] as any[], @@ -37,6 +39,10 @@ vi.mock('@/lib/market/quote-snapshots', () => ({ buildMarketQuoteSnapshot: buildMarketQuoteSnapshotMock, })) +vi.mock('@/providers/market', () => ({ + executeProviderRequest: executeProviderRequestMock, +})) + vi.mock('@/lib/logs/console/logger', () => ({ createLogger: vi.fn(() => ({ info: vi.fn(), @@ -54,8 +60,8 @@ vi.mock('@/providers/market/finnhub/config', () => ({ })) vi.mock('@/providers/market/providers', () => ({ - getMarketLiveCapabilities: getMarketLiveCapabilitiesMock, getMarketProviderConfig: getMarketProviderConfigMock, + getMarketProviderPollingIntervalMs: getMarketProviderPollingIntervalMsMock, })) vi.mock('@/providers/market/utils', () => ({ @@ -101,8 +107,8 @@ vi.mock('@/socket-server/market/finnhub', () => ({ import { MarketStreamManager, - resolveMarketSubscribeEnv, type MarketSubscribePayload, + resolveMarketSubscribeEnv, } from './manager' const listing = { @@ -131,12 +137,12 @@ describe('resolveMarketSubscribeEnv', () => { beforeEach(() => { vi.clearAllMocks() - delete process.env.RUNTIME_ONLY_KEY + process.env.RUNTIME_ONLY_KEY = undefined }) afterEach(() => { if (originalEnv === undefined) { - delete process.env.RUNTIME_ONLY_KEY + process.env.RUNTIME_ONLY_KEY = undefined return } @@ -201,16 +207,22 @@ describe('MarketStreamManager quote snapshots', () => { alpacaStreamInstances.length = 0 finnhubStreamInstances.length = 0 buildMarketQuoteSnapshotMock.mockResolvedValue(quoteSnapshot) - getMarketLiveCapabilitiesMock.mockImplementation((provider: string) => - provider === 'yahoo-finance' - ? { - supportsPolling: true, - channels: ['quote-snapshots'], - pollingIntervalMs: 5_000, - } - : null - ) + executeProviderRequestMock.mockResolvedValue({ + bars: [ + { + timeStamp: '2026-05-27T14:30:00.000Z', + open: 100, + high: 102, + low: 99, + close: 101, + volume: 1000, + }, + ], + }) getMarketProviderConfigMock.mockReturnValue({}) + getMarketProviderPollingIntervalMsMock.mockImplementation((provider: string) => + provider === 'yahoo-finance' ? 5_000 : undefined + ) resolveListingContextMock.mockResolvedValue({ listing, base: 'AAPL', @@ -366,4 +378,73 @@ describe('MarketStreamManager quote snapshots', () => { manager.removeSocket(firstSocket.id) manager.removeSocket(secondSocket.id) }) + + it('uses one polling pull for duplicate polling-provider bar streams', async () => { + vi.useFakeTimers() + const manager = new MarketStreamManager() + const firstSocket = createSocket('socket-1') + const secondSocket = createSocket('socket-2') + + await manager.subscribe(firstSocket, { + provider: 'yahoo-finance', + workspaceId: 'workspace-1', + listing, + channel: 'bars', + interval: '1m', + clientSubscriptionId: 'bars-1', + }) + await manager.subscribe(secondSocket, { + provider: 'yahoo-finance', + workspaceId: 'workspace-1', + listing, + channel: 'bars', + interval: '1m', + clientSubscriptionId: 'bars-2', + }) + + await Promise.resolve() + await Promise.resolve() + + expect(executeProviderRequestMock).toHaveBeenCalledTimes(1) + expect(executeProviderRequestMock).toHaveBeenCalledWith( + 'yahoo-finance', + expect.objectContaining({ + kind: 'series', + interval: '1m', + windows: [{ mode: 'bars', barCount: 1 }], + }) + ) + expect(firstSocket.emit).toHaveBeenCalledWith( + 'market-bar', + expect.objectContaining({ + provider: 'yahoo-finance', + channel: 'bars', + clientSubscriptionId: 'bars-1', + bar: expect.objectContaining({ close: 101 }), + }) + ) + expect(secondSocket.emit).toHaveBeenCalledWith( + 'market-bar', + expect.objectContaining({ + provider: 'yahoo-finance', + channel: 'bars', + clientSubscriptionId: 'bars-2', + bar: expect.objectContaining({ close: 101 }), + }) + ) + + executeProviderRequestMock.mockClear() + firstSocket.emit.mockClear() + secondSocket.emit.mockClear() + vi.advanceTimersByTime(5_000) + await Promise.resolve() + await Promise.resolve() + + expect(executeProviderRequestMock).toHaveBeenCalledTimes(1) + expect(firstSocket.emit).not.toHaveBeenCalled() + expect(secondSocket.emit).not.toHaveBeenCalled() + + manager.removeSocket(firstSocket.id) + manager.removeSocket(secondSocket.id) + }) }) diff --git a/apps/tradinggoose/socket-server/market/manager.ts b/apps/tradinggoose/socket-server/market/manager.ts index 7b2744467..e4a6d256c 100644 --- a/apps/tradinggoose/socket-server/market/manager.ts +++ b/apps/tradinggoose/socket-server/market/manager.ts @@ -8,13 +8,19 @@ import { type MarketQuoteSnapshot, } from '@/lib/market/quote-snapshot-contract' import { buildMarketQuoteSnapshot } from '@/lib/market/quote-snapshots' +import { executeProviderRequest } from '@/providers/market' import { alpacaProviderConfig } from '@/providers/market/alpaca/config' import { finnhubProviderConfig } from '@/providers/market/finnhub/config' import { - getMarketLiveCapabilities, getMarketProviderConfig, + getMarketProviderPollingIntervalMs, } from '@/providers/market/providers' -import type { MarketBar, MarketProviderAuth, MarketProviderParams } from '@/providers/market/types' +import type { + MarketBar, + MarketProviderAuth, + MarketProviderParams, + MarketSeries, +} from '@/providers/market/types' import { resolveListingContext, resolveProviderSymbol } from '@/providers/market/utils' import type { AuthenticatedSocket } from '@/socket-server/middleware/auth' import { @@ -31,8 +37,8 @@ const MIN_POLLING_INTERVAL_MS = 5_000 const POLLING_CONCURRENCY = 5 export type MarketProviderId = 'alpaca' | 'finnhub' -export type PollingMarketProviderId = 'alpha-vantage' | 'yahoo-finance' -export type AnyMarketProviderId = MarketProviderId | PollingMarketProviderId +export type PollingMarketProviderId = string +export type AnyMarketProviderId = string export type MarketStreamChannel = 'bars' | 'trades' | 'quotes' export type MarketChannel = MarketStreamChannel | 'quote-snapshots' @@ -99,6 +105,7 @@ interface StreamState { pollingInFlight?: boolean pollingIntervalMs?: number quoteSnapshotCache: Map + marketBarCache: Map subscribersBySymbol: Map> } @@ -384,13 +391,12 @@ export class MarketStreamManager { } const channel = payload.channel ?? 'quote-snapshots' - if (channel !== 'quote-snapshots') { - throw new Error('Polling market providers support quote snapshots only') + if (channel !== 'quote-snapshots' && channel !== 'bars') { + throw new Error('Polling market providers support bars and quote snapshots only') } - const capabilities = getMarketLiveCapabilities(payload.provider) - if (!capabilities?.supportsPolling) { - throw new Error(`Provider ${payload.provider} does not support polling market streams`) + if (channel === 'bars' && !payload.interval?.trim()) { + throw new Error('interval is required to poll market bars') } const providerConfig = getMarketProviderConfig(payload.provider) @@ -500,6 +506,15 @@ export class MarketStreamManager { } } + if (record.channel === 'bars' && record.interval) { + const cached = streamState.marketBarCache.get( + buildPollingBarCacheKey(record.symbol, record.interval) + ) + if (cached) { + this.emitMarketBar(record, cached) + } + } + if (!streamState.stream) { this.ensurePolling(streamState) } @@ -561,6 +576,7 @@ export class MarketStreamManager { auth: config.auth, providerParams: config.providerParams, quoteSnapshotCache: new Map(), + marketBarCache: new Map(), subscribersBySymbol: new Map(), } @@ -587,6 +603,7 @@ export class MarketStreamManager { providerParams: config.providerParams, pollingIntervalMs: config.pollingIntervalMs, quoteSnapshotCache: new Map(), + marketBarCache: new Map(), subscribersBySymbol: new Map(), } @@ -720,54 +737,121 @@ export class MarketStreamManager { }) } + private emitMarketBar(record: MarketSubscriptionRecord, bar: MarketBar, raw?: unknown) { + record.socket.emit('market-bar', { + provider: record.provider, + market: record.market, + channel: record.channel, + subscriptionId: record.subscriptionId, + clientSubscriptionId: record.clientSubscriptionId, + listing: record.listing, + listingBase: record.listingBase, + listingQuote: record.listingQuote, + symbol: record.symbol, + interval: record.interval, + bar, + receivedAt: new Date().toISOString(), + raw, + }) + } + + private emitMarketBarToSymbolSubscribers( + streamState: StreamState, + symbol: string, + interval: string, + bar: MarketBar, + raw?: unknown + ) { + const subscribers = streamState.subscribersBySymbol.get(symbol) + if (!subscribers) return + + subscribers.forEach((record) => { + if (record.channel !== 'bars' || record.interval !== interval) return + this.emitMarketBar(record, bar, raw) + }) + } + private ensurePolling(streamState: StreamState) { if (streamState.pollingTimer) return const intervalMs = streamState.pollingIntervalMs ?? DEFAULT_POLLING_INTERVAL_MS streamState.pollingTimer = setInterval(() => { - void this.pollQuoteSnapshots(streamState) + void this.pollMarketData(streamState) }, intervalMs) streamState.pollingTimer.unref?.() - void this.pollQuoteSnapshots(streamState) + void this.pollMarketData(streamState) } - private async pollQuoteSnapshots(streamState: StreamState) { + private async pollMarketData(streamState: StreamState) { if (streamState.pollingInFlight) return - const records = new Map() + const tasks: Array<{ + type: 'quote-snapshot' | 'bar' + symbol: string + interval?: string + record: MarketSubscriptionRecord + }> = [] streamState.subscribersBySymbol.forEach((subscribers, symbol) => { - const record = Array.from(subscribers.values()).find( + const quoteRecord = Array.from(subscribers.values()).find( (subscriber) => subscriber.channel === 'quote-snapshots' && subscriber.listing ) - if (record) records.set(symbol, record) + + if (quoteRecord) { + tasks.push({ type: 'quote-snapshot', symbol, record: quoteRecord }) + } + + const barRecordsByInterval = new Map() + subscribers.forEach((subscriber) => { + if (subscriber.channel !== 'bars' || !subscriber.listing || !subscriber.interval) return + if (!barRecordsByInterval.has(subscriber.interval)) { + barRecordsByInterval.set(subscriber.interval, subscriber) + } + }) + barRecordsByInterval.forEach((record, interval) => { + tasks.push({ type: 'bar', symbol, interval, record }) + }) }) - if (records.size === 0) return + if (tasks.length === 0) return streamState.pollingInFlight = true try { - const pending = Array.from(records.entries()) + const pending = [...tasks] const workers = Array.from( { length: Math.min(POLLING_CONCURRENCY, pending.length) }, async () => { while (pending.length > 0) { const next = pending.shift() if (!next) return - const [symbol, record] = next try { - const snapshot = await buildMarketQuoteSnapshot({ - provider: record.provider, - listing: record.listing as ListingIdentity, - auth: streamState.auth, - providerParams: streamState.providerParams, - }) - streamState.quoteSnapshotCache.set(symbol, snapshot) - this.emitQuoteSnapshotToSymbolSubscribers(streamState, symbol, snapshot) + if (next.type === 'quote-snapshot') { + const snapshot = await buildMarketQuoteSnapshot({ + provider: next.record.provider, + listing: next.record.listing as ListingIdentity, + auth: streamState.auth, + providerParams: streamState.providerParams, + }) + streamState.quoteSnapshotCache.set(next.symbol, snapshot) + this.emitQuoteSnapshotToSymbolSubscribers(streamState, next.symbol, snapshot) + continue + } + + await this.pollMarketBar(streamState, next.symbol, next.interval!, next.record) } catch (error) { - const snapshot = createEmptyMarketQuoteSnapshot( - error instanceof Error ? error.message : 'Failed to poll quote snapshot' + if (next.type === 'quote-snapshot') { + const snapshot = createEmptyMarketQuoteSnapshot( + error instanceof Error ? error.message : 'Failed to poll quote snapshot' + ) + streamState.quoteSnapshotCache.set(next.symbol, snapshot) + this.emitQuoteSnapshotToSymbolSubscribers(streamState, next.symbol, snapshot) + continue + } + + this.emitMarketPollingError( + streamState, + next.symbol, + next.interval!, + error instanceof Error ? error.message : 'Failed to poll market bar' ) - streamState.quoteSnapshotCache.set(symbol, snapshot) - this.emitQuoteSnapshotToSymbolSubscribers(streamState, symbol, snapshot) } } } @@ -779,6 +863,57 @@ export class MarketStreamManager { } } + private async pollMarketBar( + streamState: StreamState, + symbol: string, + interval: string, + record: MarketSubscriptionRecord + ) { + const response = await executeProviderRequest(record.provider, { + kind: 'series', + listing: record.listing as ListingIdentity, + interval, + auth: streamState.auth, + providerParams: { + ...(streamState.providerParams ?? {}), + allowEmpty: true, + }, + windows: [{ mode: 'bars', barCount: 1 }], + }) + const series = response as MarketSeries + const bar = series.bars[series.bars.length - 1] + if (!bar) return + + const cacheKey = buildPollingBarCacheKey(symbol, interval) + const cached = streamState.marketBarCache.get(cacheKey) + if (cached && areMarketBarsEqual(cached, bar)) return + + streamState.marketBarCache.set(cacheKey, bar) + this.emitMarketBarToSymbolSubscribers(streamState, symbol, interval, bar) + } + + private emitMarketPollingError( + streamState: StreamState, + symbol: string, + interval: string, + message: string + ) { + const subscribers = streamState.subscribersBySymbol.get(symbol) + if (!subscribers) return + + subscribers.forEach((record) => { + if (record.channel !== 'bars' || record.interval !== interval) return + record.socket.emit('market-error', { + provider: record.provider, + market: record.market, + channel: record.channel, + subscriptionId: record.subscriptionId, + clientSubscriptionId: record.clientSubscriptionId, + message, + }) + }) + } + private handleStreamError(streamKey: string, message: string, detail?: any) { const state = this.streams.get(streamKey) if (!state) return @@ -887,10 +1022,8 @@ export class MarketStreamManager { export const marketStreamManager = new MarketStreamManager() function resolveProviderId(provider?: AnyMarketProviderId): AnyMarketProviderId { - if (provider === 'finnhub') return 'finnhub' - if (provider === 'yahoo-finance') return 'yahoo-finance' - if (provider === 'alpha-vantage') return 'alpha-vantage' - if (provider === 'alpaca') return 'alpaca' + const providerId = typeof provider === 'string' ? provider.trim() : '' + if (providerId && getMarketProviderConfig(providerId)) return providerId throw new Error('market provider is required') } @@ -994,6 +1127,22 @@ function buildPollingStreamKey(config: { return createHash('sha256').update(base).digest('hex') } +function buildPollingBarCacheKey(symbol: string, interval: string): string { + return `${symbol}|${interval}` +} + +function areMarketBarsEqual(left: MarketBar, right: MarketBar): boolean { + return ( + left.timeStamp === right.timeStamp && + left.open === right.open && + left.high === right.high && + left.low === right.low && + left.close === right.close && + left.volume === right.volume && + left.turnover === right.turnover + ) +} + function createSubscriptionId({ streamKey, channel, @@ -1007,13 +1156,9 @@ function createSubscriptionId({ interval: string clientSubscriptionId?: string }) { - return [ - streamKey, - channel, - symbol, - interval, - clientSubscriptionId?.trim() || randomUUID(), - ].join(':') + return [streamKey, channel, symbol, interval, clientSubscriptionId?.trim() || randomUUID()].join( + ':' + ) } function resolvePollingIntervalMs( @@ -1021,10 +1166,9 @@ function resolvePollingIntervalMs( providerParams?: MarketProviderParams ): number { const configured = Number(providerParams?.pollingIntervalMs ?? providerParams?.pollIntervalMs) - const capabilityDefault = - getMarketLiveCapabilities(provider)?.pollingIntervalMs ?? DEFAULT_POLLING_INTERVAL_MS - const requested = - Number.isFinite(configured) && configured > 0 ? configured : capabilityDefault + const providerDefault = + getMarketProviderPollingIntervalMs(provider) ?? DEFAULT_POLLING_INTERVAL_MS + const requested = Number.isFinite(configured) && configured > 0 ? configured : providerDefault return Math.max(MIN_POLLING_INTERVAL_MS, requested) } @@ -1036,10 +1180,7 @@ function updateSnapshotFromTrade( if (price === null) return previous ?? createEmptyMarketQuoteSnapshot() const previousClose = previous?.previousClose ?? null - const change = - previousClose !== null - ? price - previousClose - : (previous?.change ?? null) + const change = previousClose !== null ? price - previousClose : (previous?.change ?? null) const changePercent = previousClose !== null && previousClose !== 0 ? ((price - previousClose) / previousClose) * 100 diff --git a/apps/tradinggoose/socket-server/monitor-runtime-lock.ts b/apps/tradinggoose/socket-server/monitor-runtime-lock.ts new file mode 100644 index 000000000..d182b06b5 --- /dev/null +++ b/apps/tradinggoose/socket-server/monitor-runtime-lock.ts @@ -0,0 +1,130 @@ +import { randomUUID } from 'node:crypto' +import { + acquireLock, + getRedisClient, + getRedisStorageMode, + releaseLock, + renewLock, +} from '@/lib/redis' + +export type MonitorRuntimeStatus = 'not_initialized' | 'running' | 'degraded' | 'disabled' + +export type MonitorRuntimeLockHealth = { + mode: 'fail_closed' + redisConfigured: boolean + redisClientAvailable: boolean + degraded: boolean +} + +type LoggerLike = { + warn: (message: string, ...args: unknown[]) => void +} + +type RuntimeLockOptions = { + key: string + label: string + logger: LoggerLike + onLost: (error: unknown) => Promise | void +} + +const LOCK_EXPIRY_SECONDS = 90 +const LOCK_REFRESH_INTERVAL_MS = 30_000 +const DATABASE_CONNECTION_ERROR_CODES = new Set([ + 'ECONNREFUSED', + 'ECONNRESET', + 'ETIMEDOUT', + 'ENOTFOUND', + 'EHOSTUNREACH', +]) + +export function isMonitorRuntimeDatabaseConnectionError(error: unknown): boolean { + const seen = new Set() + let current: unknown = error + + while (current && typeof current === 'object' && !seen.has(current)) { + seen.add(current) + + const code = (current as { code?: unknown }).code + if (typeof code === 'string' && DATABASE_CONNECTION_ERROR_CODES.has(code)) { + return true + } + + current = (current as { cause?: unknown }).cause + } + + return false +} + +export const getMonitorRuntimeUnavailableStatus = (): MonitorRuntimeStatus => + getRedisStorageMode() === 'redis' ? 'degraded' : 'disabled' + +export const getMonitorRuntimeLockHealth = ( + status: MonitorRuntimeStatus +): MonitorRuntimeLockHealth => { + const redisConfigured = getRedisStorageMode() === 'redis' + const redisClientAvailable = Boolean(getRedisClient()) + + return { + mode: 'fail_closed', + redisConfigured, + redisClientAvailable, + degraded: status === 'degraded' || (redisConfigured && !redisClientAvailable), + } +} + +export function createMonitorRuntimeLock({ key, label, logger, onLost }: RuntimeLockOptions) { + const instanceId = randomUUID() + let held = false + let timer: ReturnType | null = null + + const release = async () => { + if (!held) return + + try { + await releaseLock(key, instanceId) + } catch (error) { + logger.warn(`Failed to release ${label.toLowerCase()} runtime lock`, { error }) + } finally { + held = false + } + } + + const refresh = async () => { + if (!held) return + + try { + const renewed = await renewLock(key, instanceId, LOCK_EXPIRY_SECONDS) + if (renewed) return + + held = false + await onLost(new Error(`${label} runtime lock ownership was lost`)) + } catch (error) { + held = false + await onLost(error) + } + } + + return { + getHealth: getMonitorRuntimeLockHealth, + acquire: async () => { + try { + held = await acquireLock(key, instanceId, LOCK_EXPIRY_SECONDS) + } catch (error) { + held = false + logger.warn(`${label} runtime lock acquisition error`, { error }) + } + return held + }, + startRenewal: () => { + if (timer) return + timer = setInterval(() => void refresh(), LOCK_REFRESH_INTERVAL_MS) + timer.unref?.() + }, + stopRenewal: () => { + if (!timer) return + clearInterval(timer) + timer = null + }, + release, + } +} diff --git a/apps/tradinggoose/socket-server/routes/http.ts b/apps/tradinggoose/socket-server/routes/http.ts index ac414f4d2..36c238f26 100644 --- a/apps/tradinggoose/socket-server/routes/http.ts +++ b/apps/tradinggoose/socket-server/routes/http.ts @@ -1,21 +1,24 @@ import type { IncomingMessage, ServerResponse } from 'http' import * as Y from 'yjs' -import { env } from '@/lib/env' import { buildReviewTargetDescriptorFromEnvelope, parseYjsTransportEnvelope, } from '@/lib/copilot/review-sessions/identity' import type { ReviewEntityKind } from '@/lib/copilot/review-sessions/types' -import { getRedisClient, getRedisStorageMode } from '@/lib/redis' -import { getRuntimeStateFromDoc, getRuntimeStateFromUpdate } from '@/lib/yjs/server/bootstrap-review-target' +import { env } from '@/lib/env' import { seedEntitySession } from '@/lib/yjs/entity-session' +import { + getRuntimeStateFromDoc, + getRuntimeStateFromUpdate, +} from '@/lib/yjs/server/bootstrap-review-target' +import { YJS_ORIGINS } from '@/lib/yjs/transaction-origins' import { getMetadataMap as getWorkflowMetadataMap, setVariables, setWorkflowState, type WorkflowSnapshot, } from '@/lib/yjs/workflow-session' -import { YJS_ORIGINS } from '@/lib/yjs/transaction-origins' +import { getMonitorRuntimeLockHealth } from '@/socket-server/monitor-runtime-lock' import { deleteSession, getState, storeState } from '@/socket-server/yjs/persistence' import { getExistingDocument, removeDocument } from '@/socket-server/yjs/upstream-utils' @@ -26,31 +29,20 @@ interface Logger { warn: (message: string, ...args: any[]) => void } -type MonitorRuntimeStatus = 'not_initialized' | 'running' | 'degraded' | 'disabled' - -type MonitorRuntimeHealth = { - enabled: boolean - status: MonitorRuntimeStatus - reconcileEndpointEnabled: boolean - lock: { - mode: 'fail_closed' - redisConfigured: boolean - redisClientAvailable: boolean - degraded: boolean - } -} +type MonitorRuntimeHealth = Record type HttpHandlerOptions = { getMonitorRuntimeHealth?: () => MonitorRuntimeHealth getConnectionCount?: () => number - onIndicatorMonitorsReconcile?: () => Promise | void + onMonitorsReconcile?: () => Promise | void } const INTERNAL_SECRET_HEADER = 'x-internal-secret' const INTERNAL_YJS_WORKFLOW_APPLY_PATH = /^\/internal\/yjs\/workflows\/([^/]+)\/apply-state$/ const INTERNAL_YJS_ENTITY_APPLY_PATH = /^\/internal\/yjs\/entities\/([^/]+)\/apply-state$/ const INTERNAL_YJS_SNAPSHOT_PATH = /^\/internal\/yjs\/sessions\/([^/]+)\/snapshot$/ -const INTERNAL_YJS_SESSION_CLEAR_RESEEDED_PATH = /^\/internal\/yjs\/sessions\/([^/]+)\/clear-reseeded$/ +const INTERNAL_YJS_SESSION_CLEAR_RESEEDED_PATH = + /^\/internal\/yjs\/sessions\/([^/]+)\/clear-reseeded$/ const INTERNAL_YJS_SESSION_PATH = /^\/internal\/yjs\/sessions\/([^/]+)$/ type ApplyWorkflowStateRequest = { @@ -107,19 +99,33 @@ function rejectUnauthorizedRequest( } function getDefaultMonitorRuntimeHealth(): MonitorRuntimeHealth { - const redisConfigured = getRedisStorageMode() === 'redis' - const redisClientAvailable = Boolean(getRedisClient()) - const degraded = redisConfigured && !redisClientAvailable + const defaultStatus = getMonitorRuntimeLockHealth('not_initialized').degraded + ? 'degraded' + : 'not_initialized' + const lock = getMonitorRuntimeLockHealth(defaultStatus) return { - enabled: false, - status: degraded ? 'degraded' : 'not_initialized', - reconcileEndpointEnabled: true, - lock: { - mode: 'fail_closed', - redisConfigured, - redisClientAvailable, - degraded, + indicator: { + enabled: false, + status: defaultStatus, + lock, + stats: { + activeSubscriptions: 0, + lastReconcileAt: null, + lastReconcileError: null, + dispatchedCount: 0, + skippedCount: 0, + }, + }, + portfolio: { + enabled: false, + status: defaultStatus, + lock, + stats: { + activeSubscriptions: 0, + lastReconcileAt: null, + lastReconcileError: null, + }, }, } } @@ -411,7 +417,12 @@ async function handleInternalYjsSnapshotRequest( } } -function matchInternalRoute(pathname: string, pattern: RegExp, method: string, reqMethod?: string): string | null { +function matchInternalRoute( + pathname: string, + pattern: RegExp, + method: string, + reqMethod?: string +): string | null { if (reqMethod !== method) return null const match = pathname.match(pattern)?.[1] return match ? decodeURIComponent(match) : null @@ -467,7 +478,12 @@ async function handleInternalYjsRequest( return true } - const deleteId = matchInternalRoute(parsedUrl.pathname, INTERNAL_YJS_SESSION_PATH, 'DELETE', req.method) + const deleteId = matchInternalRoute( + parsedUrl.pathname, + INTERNAL_YJS_SESSION_PATH, + 'DELETE', + req.method + ) if (deleteId) { await handleInternalYjsSessionDeleteRequest(res, logger, deleteId) return true @@ -476,14 +492,11 @@ async function handleInternalYjsRequest( return false } -export function createHttpHandler( - logger: Logger, - options?: HttpHandlerOptions -) { +export function createHttpHandler(logger: Logger, options?: HttpHandlerOptions) { const resolveMonitorRuntimeHealth = options?.getMonitorRuntimeHealth ?? getDefaultMonitorRuntimeHealth const resolveConnectionCount = options?.getConnectionCount ?? (() => 0) - const triggerIndicatorMonitorsReconcile = options?.onIndicatorMonitorsReconcile + const triggerMonitorsReconcile = options?.onMonitorsReconcile return async (req: IncomingMessage, res: ServerResponse) => { if (res.writableEnded || res.headersSent) { @@ -507,16 +520,16 @@ export function createHttpHandler( return } - if (req.method === 'POST' && req.url === '/internal/indicator-monitors/reconcile') { + if (req.method === 'POST' && req.url === '/internal/monitors/reconcile') { if (rejectUnauthorizedRequest(req, res, logger)) return try { - await triggerIndicatorMonitorsReconcile?.() - logger.info('Accepted indicator monitor reconcile request') + await triggerMonitorsReconcile?.() + logger.info('Accepted monitor reconcile request') res.writeHead(200, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ success: true })) } catch (error) { - logger.error('Failed to process indicator monitor reconcile request', { error }) + logger.error('Failed to process monitor reconcile request', { error }) res.writeHead(500, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: 'Failed to process reconcile request' })) } diff --git a/apps/tradinggoose/socket-server/trading/portfolio-manager.test.ts b/apps/tradinggoose/socket-server/trading/portfolio-manager.test.ts index 7e612872e..b9cfa8f30 100644 --- a/apps/tradinggoose/socket-server/trading/portfolio-manager.test.ts +++ b/apps/tradinggoose/socket-server/trading/portfolio-manager.test.ts @@ -11,6 +11,7 @@ const { getTradingPortfolioSupportedWindowsMock, isTradingPortfolioWindowSupportedMock, resolveOAuthConnectionAccountForUserMock, + checkWorkspaceAccessMock, listTradingPortfolioIdentitiesMock, getPortfolioDetailMock, getTradingAccountPerformanceMock, @@ -22,6 +23,7 @@ const { getTradingPortfolioSupportedWindowsMock: vi.fn(), isTradingPortfolioWindowSupportedMock: vi.fn(), resolveOAuthConnectionAccountForUserMock: vi.fn(), + checkWorkspaceAccessMock: vi.fn(), listTradingPortfolioIdentitiesMock: vi.fn(), getPortfolioDetailMock: vi.fn(), getTradingAccountPerformanceMock: vi.fn(), @@ -36,6 +38,10 @@ vi.mock('@/lib/credentials/oauth', () => ({ resolveOAuthConnectionAccountForUserMock(...args), })) +vi.mock('@/lib/permissions/utils', () => ({ + checkWorkspaceAccess: (...args: unknown[]) => checkWorkspaceAccessMock(...args), +})) + vi.mock('@/lib/trading/portfolio-identities', () => ({ listTradingPortfolioIdentities: (...args: unknown[]) => listTradingPortfolioIdentitiesMock(...args), @@ -71,7 +77,7 @@ import { TradingPortfolioStreamManager } from './portfolio-manager' const portfolioIdentity: PortfolioIdentity = { providerId: 'alpaca', - tokenAccountId: 'oauth-account-1', + credentialId: 'oauth-credential-1', serviceId: 'alpaca-live', accountId: 'acct-1', providerName: 'Alpaca', @@ -88,18 +94,11 @@ const portfolioDetail = { cashBalances: [], positions: [ { - symbol: { - base: 'AAPL', - quote: 'USD', - listing: { - listing_id: 'TG_LSTG_AAPL', - base_id: '', - quote_id: '', - listing_type: 'default', - }, - assetClass: 'stock' as const, - active: true, - rank: 0, + listingIdentity: { + listing_id: 'TG_LSTG_AAPL', + base_id: '', + quote_id: '', + listing_type: 'default', }, quantity: 2, }, @@ -144,10 +143,11 @@ describe('TradingPortfolioStreamManager', () => { beforeEach(() => { vi.clearAllMocks() resolveOAuthConnectionAccountForUserMock.mockResolvedValue({ - tokenAccountId: 'oauth-account-1', + tokenAccountId: 'oauth-credential-1', credentialOwnerUserId: 'user-1', providerId: 'alpaca-live', }) + checkWorkspaceAccessMock.mockResolvedValue({ exists: true, hasAccess: true }) refreshAccessTokenIfNeededMock.mockResolvedValue('oauth-token') getTradingProviderDefinitionMock.mockReturnValue({ id: 'alpaca', @@ -173,6 +173,7 @@ describe('TradingPortfolioStreamManager', () => { const secondSocket = createSocket('socket-2') await manager.subscribe(firstSocket, { + workspaceId: 'workspace-1', provider: 'alpaca', serviceId: 'alpaca-live', portfolioIdentity, @@ -180,6 +181,7 @@ describe('TradingPortfolioStreamManager', () => { clientSubscriptionId: 'snapshot-1', }) await manager.subscribe(secondSocket, { + workspaceId: 'workspace-1', provider: 'alpaca', serviceId: 'alpaca-live', portfolioIdentity, @@ -195,12 +197,14 @@ describe('TradingPortfolioStreamManager', () => { userId: 'user-1', providerId: 'alpaca', serviceId: 'alpaca-live', + credentialId: 'oauth-credential-1', requestId: expect.any(String), }) expect(getPortfolioDetailMock).toHaveBeenCalledTimes(1) expect(getPortfolioDetailMock).toHaveBeenCalledWith({ providerId: 'alpaca', - tokenAccountId: 'oauth-account-1', + credentialId: 'oauth-credential-1', + tokenAccountId: 'oauth-credential-1', serviceId: 'alpaca-live', environment: 'live', accessToken: 'oauth-token', @@ -235,6 +239,7 @@ describe('TradingPortfolioStreamManager', () => { const secondSocket = createSocket('socket-2') const first = await manager.subscribe(firstSocket, { + workspaceId: 'workspace-1', provider: 'alpaca', serviceId: 'alpaca-live', portfolioIdentity, @@ -242,6 +247,7 @@ describe('TradingPortfolioStreamManager', () => { clientSubscriptionId: 'portfolio_snapshot', }) const second = await manager.subscribe(secondSocket, { + workspaceId: 'workspace-1', provider: 'alpaca', serviceId: 'alpaca-live', portfolioIdentity, @@ -278,6 +284,7 @@ describe('TradingPortfolioStreamManager', () => { const socket = createSocket('socket-1') await manager.subscribe(socket, { + workspaceId: 'workspace-1', provider: 'alpaca', serviceId: 'alpaca-live', portfolioIdentity, @@ -285,6 +292,7 @@ describe('TradingPortfolioStreamManager', () => { clientSubscriptionId: 'snapshot-1', }) await manager.subscribe(socket, { + workspaceId: 'workspace-1', provider: 'alpaca', serviceId: 'alpaca-live', portfolioIdentity, @@ -319,6 +327,7 @@ describe('TradingPortfolioStreamManager', () => { const socket = createSocket('socket-1') await manager.subscribe(socket, { + workspaceId: 'workspace-1', provider: 'alpaca', serviceId: 'alpaca-live', portfolioIdentity, @@ -333,6 +342,7 @@ describe('TradingPortfolioStreamManager', () => { manager.stop() await expect( manager.subscribe(socket, { + workspaceId: 'workspace-1', provider: 'alpaca', serviceId: 'alpaca-live', portfolioIdentity, diff --git a/apps/tradinggoose/socket-server/trading/portfolio-manager.ts b/apps/tradinggoose/socket-server/trading/portfolio-manager.ts index 30cbc0b2e..1f61dfc5c 100644 --- a/apps/tradinggoose/socket-server/trading/portfolio-manager.ts +++ b/apps/tradinggoose/socket-server/trading/portfolio-manager.ts @@ -1,7 +1,10 @@ import { createHash, randomUUID } from 'crypto' -import { resolveOAuthConnectionAccountForUser } from '@/lib/credentials/oauth' import { createLogger } from '@/lib/logs/console/logger' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/tokens' +import { checkWorkspaceAccess } from '@/lib/permissions/utils' +import { + authorizeTradingConnectionRequest, + resolveTradingProviderContext, +} from '@/lib/trading/context' import { listTradingPortfolioIdentities } from '@/lib/trading/portfolio-identities' import { getPortfolioDetail, @@ -19,7 +22,6 @@ import { import { TradingBrokerRequestError } from '@/providers/trading/portfolio-utils' import { getTradingProviderDefinition, - getTradingProviderOAuthEnvironment, getTradingProviderOAuthServiceId, } from '@/providers/trading/providers' import type { @@ -41,6 +43,7 @@ const CHANNEL_POLL_INTERVAL_MS: Record = { export type TradingPortfolioChannel = 'accounts' | 'account-snapshot' | 'portfolio-performance' export interface TradingPortfolioSubscribePayload { + workspaceId?: string provider?: string serviceId?: string portfolioIdentity?: PortfolioIdentity | null @@ -48,9 +51,11 @@ export interface TradingPortfolioSubscribePayload { channel?: TradingPortfolioChannel clientSubscriptionId?: string forceRefresh?: boolean + pollIntervalSeconds?: number } export interface TradingPortfolioUnsubscribePayload { + workspaceId?: string subscriptionId?: string clientSubscriptionId?: string provider?: string @@ -63,27 +68,33 @@ export interface TradingPortfolioSubscriptionInfo { subscriptionId: string clientSubscriptionId?: string provider: TradingProviderId + workspaceId?: string serviceId?: string portfolioIdentity?: PortfolioIdentity channel: TradingPortfolioChannel window?: TradingPortfolioPerformanceWindow + pollIntervalMs?: number } interface TradingPortfolioSubscriptionRecord extends TradingPortfolioSubscriptionInfo { streamKey: string - socketId: string - socket: AuthenticatedSocket + socketId?: string + socket?: AuthenticatedSocket + onData?: (payload: TradingPortfolioDataPayload) => void | Promise + onError?: (error: unknown, payload: TradingPortfolioErrorPayload) => void } interface TradingPortfolioStreamState { streamKey: string userId: string + workspaceId?: string providerId: TradingProviderId serviceId?: string portfolioIdentity?: PortfolioIdentity channel: TradingPortfolioChannel window?: TradingPortfolioPerformanceWindow pollingTimer?: ReturnType + pollingIntervalMs?: number pollingInFlight?: boolean lastPayload?: TradingPortfolioDataPayload subscribers: Map @@ -97,6 +108,7 @@ interface AccountsCacheEntry { type TradingPortfolioBasePayload = { provider: TradingProviderId + workspaceId?: string serviceId?: string channel: TradingPortfolioChannel receivedAt: string @@ -120,11 +132,15 @@ type TradingPortfolioPerformancePayload = TradingPortfolioBasePayload & { performance: Awaited> } -type TradingPortfolioDataPayload = +export type TradingPortfolioDataPayload = | TradingPortfolioAccountsPayload | TradingPortfolioSnapshotPayload | TradingPortfolioPerformancePayload +export type TradingPortfolioErrorPayload = TradingPortfolioSubscriptionInfo & { + message: string +} + function redactPortfolioIdentity(portfolioIdentity?: PortfolioIdentity | null) { if (!portfolioIdentity) return undefined return { @@ -147,85 +163,196 @@ export class TradingPortfolioStreamManager { socket: AuthenticatedSocket, payload: TradingPortfolioSubscribePayload ): Promise { - if (this.stopped) throw new Error('Trading portfolio stream manager is stopped') - const userId = socket.userId if (!userId) throw new Error('Authentication required') - const providerId = resolveTradingProviderId(payload.provider, payload.portfolioIdentity) + const { record } = this.addSubscription({ + userId, + workspaceId: payload.workspaceId, + provider: payload.provider, + serviceId: payload.serviceId, + portfolioIdentity: payload.portfolioIdentity, + channel: payload.channel, + window: payload.window, + forceRefresh: payload.forceRefresh, + pollIntervalSeconds: payload.pollIntervalSeconds, + clientSubscriptionId: payload.clientSubscriptionId, + socket, + }) + + logger.info('Trading portfolio subscription added', { + socketId: socket.id, + userId, + providerId: record.provider, + workspaceId: record.workspaceId, + serviceId: record.serviceId, + portfolioIdentity: redactPortfolioIdentity(record.portfolioIdentity), + channel: record.channel, + window: record.window, + }) + + return toSubscriptionInfo(record) + } + + subscribeData({ + userId, + workspaceId, + provider, + serviceId: requestedServiceId, + portfolioIdentity: requestedPortfolioIdentity, + channel, + window: requestedWindow, + forceRefresh, + pollIntervalSeconds, + clientSubscriptionId, + onData, + onError, + }: { + userId: string + workspaceId: string + provider: string + serviceId?: string + portfolioIdentity?: PortfolioIdentity | null + channel: TradingPortfolioChannel + window?: TradingPortfolioPerformanceWindow + forceRefresh?: boolean + pollIntervalSeconds?: number + clientSubscriptionId?: string + onData: (payload: TradingPortfolioDataPayload) => void | Promise + onError?: (error: unknown, payload: TradingPortfolioErrorPayload) => void + }): TradingPortfolioSubscriptionInfo & { + unsubscribe: () => void + refresh: () => void + } { + const { record, streamState } = this.addSubscription({ + userId, + workspaceId, + provider, + serviceId: requestedServiceId, + portfolioIdentity: requestedPortfolioIdentity, + channel, + window: requestedWindow, + forceRefresh, + pollIntervalSeconds, + clientSubscriptionId, + onData, + onError, + }) - const channel = resolveChannel(payload.channel) + return { + ...toSubscriptionInfo(record), + unsubscribe: () => this.removeRecord(record), + refresh: () => void this.pollState(streamState, true), + } + } + + private addSubscription({ + userId, + workspaceId, + provider, + serviceId: requestedServiceId, + portfolioIdentity: requestedPortfolioIdentity, + channel, + window: requestedWindow, + forceRefresh, + pollIntervalSeconds, + clientSubscriptionId, + socket, + onData, + onError, + }: { + userId: string + workspaceId?: string + provider?: string + serviceId?: string + portfolioIdentity?: PortfolioIdentity | null + channel?: TradingPortfolioChannel + window?: TradingPortfolioPerformanceWindow + forceRefresh?: boolean + pollIntervalSeconds?: number + clientSubscriptionId?: string + socket?: AuthenticatedSocket + onData?: (payload: TradingPortfolioDataPayload) => void | Promise + onError?: (error: unknown, payload: TradingPortfolioErrorPayload) => void + }) { + if (this.stopped) throw new Error('Trading portfolio stream manager is stopped') + + const providerId = resolveTradingProviderId(provider, requestedPortfolioIdentity) + const resolvedChannel = resolveChannel(channel) + const resolvedWorkspaceId = resolveWorkspaceId(workspaceId, resolvedChannel) const serviceId = resolveServiceId( providerId, - payload.serviceId ?? toPortfolioValueObject(payload.portfolioIdentity)?.serviceId + requestedServiceId ?? toPortfolioValueObject(requestedPortfolioIdentity)?.serviceId + ) + const portfolioIdentity = resolvePortfolioIdentity( + resolvedChannel, + { + workspaceId: resolvedWorkspaceId, + provider, + serviceId, + portfolioIdentity: requestedPortfolioIdentity, + channel: resolvedChannel, + window: requestedWindow, + }, + providerId, + serviceId ) - const portfolioIdentity = resolvePortfolioIdentity(channel, payload, providerId, serviceId) - const window = resolvePerformanceWindow(providerId, channel, payload.window) + const window = resolvePerformanceWindow(providerId, resolvedChannel, requestedWindow) + const pollIntervalMs = normalizePollIntervalMs(resolvedChannel, pollIntervalSeconds) const streamKey = buildStreamKey({ userId, + workspaceId: resolvedWorkspaceId, providerId, serviceId, portfolioIdentity, - channel, + channel: resolvedChannel, window, }) const streamState = this.getOrCreateStreamState({ streamKey, userId, + workspaceId: resolvedWorkspaceId, providerId, serviceId, portfolioIdentity, - channel, + channel: resolvedChannel, window, }) const subscriptionId = createSubscriptionId({ streamKey, - socketId: socket.id, - clientSubscriptionId: payload.clientSubscriptionId, + socketId: socket?.id ?? `data:${randomUUID()}`, + clientSubscriptionId, }) const record: TradingPortfolioSubscriptionRecord = { subscriptionId, - clientSubscriptionId: payload.clientSubscriptionId, + clientSubscriptionId, streamKey, - socketId: socket.id, + socketId: socket?.id, socket, provider: providerId, + workspaceId: resolvedWorkspaceId, serviceId, portfolioIdentity, - channel, + channel: resolvedChannel, window, + pollIntervalMs, + onData, + onError, } streamState.subscribers.set(subscriptionId, record) - const socketMap = this.socketSubscriptions.get(socket.id) ?? new Map() - socketMap.set(subscriptionId, record) - this.socketSubscriptions.set(socket.id, socketMap) + if (socket) { + const socketMap = this.socketSubscriptions.get(socket.id) ?? new Map() + socketMap.set(subscriptionId, record) + this.socketSubscriptions.set(socket.id, socketMap) + } if (streamState.lastPayload) { - this.emitData(record, streamState.lastPayload) + void this.emitData(record, streamState.lastPayload) } + this.ensurePolling(streamState, Boolean(forceRefresh)) - this.ensurePolling(streamState, Boolean(payload.forceRefresh)) - - logger.info('Trading portfolio subscription added', { - socketId: socket.id, - userId, - providerId, - serviceId, - portfolioIdentity: redactPortfolioIdentity(portfolioIdentity), - channel, - window, - }) - - return { - subscriptionId, - clientSubscriptionId: payload.clientSubscriptionId, - provider: providerId, - serviceId, - portfolioIdentity, - channel, - window, - } + return { record, streamState } } unsubscribe( @@ -290,8 +417,19 @@ export class TradingPortfolioStreamManager { } private ensurePolling(streamState: TradingPortfolioStreamState, forceRefresh: boolean) { + const intervalMs = Math.min( + ...Array.from(streamState.subscribers.values()).map( + (subscriber) => subscriber.pollIntervalMs ?? CHANNEL_POLL_INTERVAL_MS[streamState.channel] + ) + ) + + if (streamState.pollingTimer && streamState.pollingIntervalMs !== intervalMs) { + clearInterval(streamState.pollingTimer) + streamState.pollingTimer = undefined + } + if (!streamState.pollingTimer) { - const intervalMs = CHANNEL_POLL_INTERVAL_MS[streamState.channel] + streamState.pollingIntervalMs = intervalMs streamState.pollingTimer = setInterval(() => { void this.pollState(streamState, false) }, intervalMs) @@ -314,13 +452,14 @@ export class TradingPortfolioStreamManager { const portfolioIdentities = await this.getAccounts(streamState, forceRefresh) const payload: TradingPortfolioAccountsPayload = { provider: streamState.providerId, + workspaceId: streamState.workspaceId, serviceId: streamState.serviceId, channel: 'accounts', portfolioIdentities, receivedAt: new Date().toISOString(), } streamState.lastPayload = payload - this.emitToSubscribers(streamState, payload) + await this.emitToSubscribers(streamState, payload) return } @@ -330,6 +469,7 @@ export class TradingPortfolioStreamManager { if (streamState.channel === 'account-snapshot') { const portfolioDetail = await getPortfolioDetail({ providerId: context.providerId, + credentialId: context.credentialId, tokenAccountId: context.tokenAccountId, serviceId: context.serviceId, environment: context.environment, @@ -338,6 +478,7 @@ export class TradingPortfolioStreamManager { }) const payload: TradingPortfolioSnapshotPayload = { provider: streamState.providerId, + workspaceId: streamState.workspaceId, serviceId: streamState.serviceId, channel: 'account-snapshot', portfolioIdentity: toPortfolioValueObject(portfolioDetail) ?? portfolioIdentity, @@ -345,7 +486,7 @@ export class TradingPortfolioStreamManager { receivedAt: new Date().toISOString(), } streamState.lastPayload = payload - this.emitToSubscribers(streamState, payload) + await this.emitToSubscribers(streamState, payload) return } @@ -355,6 +496,7 @@ export class TradingPortfolioStreamManager { const performance = await getTradingAccountPerformance({ providerId: context.providerId, + credentialId: context.credentialId, tokenAccountId: context.tokenAccountId, serviceId: context.serviceId, environment: context.environment, @@ -364,6 +506,7 @@ export class TradingPortfolioStreamManager { }) const payload: TradingPortfolioPerformancePayload = { provider: streamState.providerId, + workspaceId: streamState.workspaceId, serviceId: streamState.serviceId, channel: 'portfolio-performance', portfolioIdentity, @@ -372,7 +515,7 @@ export class TradingPortfolioStreamManager { receivedAt: new Date().toISOString(), } streamState.lastPayload = payload - this.emitToSubscribers(streamState, payload) + await this.emitToSubscribers(streamState, payload) } catch (error) { if (this.stopped) return this.emitErrorToSubscribers(streamState, error) @@ -401,6 +544,7 @@ export class TradingPortfolioStreamManager { userId: streamState.userId, providerId: streamState.providerId, serviceId: streamState.serviceId, + credentialId: streamState.portfolioIdentity?.credentialId, requestId: streamState.streamKey, }) this.accountsCache.set(cacheKey, { @@ -441,17 +585,25 @@ export class TradingPortfolioStreamManager { return account } - private emitToSubscribers( + private async emitToSubscribers( streamState: TradingPortfolioStreamState, payload: TradingPortfolioDataPayload ) { - streamState.subscribers.forEach((record) => this.emitData(record, payload)) + await Promise.all( + Array.from(streamState.subscribers.values()).map((record) => this.emitData(record, payload)) + ) } - private emitData( + private async emitData( record: TradingPortfolioSubscriptionRecord, payload: TradingPortfolioDataPayload ) { + if (record.onData) { + await record.onData(payload) + return + } + if (!record.socket) return + const basePayload = { ...payload, subscriptionId: record.subscriptionId, @@ -490,8 +642,9 @@ export class TradingPortfolioStreamManager { } streamState.subscribers.forEach((record) => { - record.socket.emit('trading-portfolio-error', { + const payload: TradingPortfolioErrorPayload = { provider: record.provider, + workspaceId: record.workspaceId, serviceId: record.serviceId, portfolioIdentity: record.portfolioIdentity, channel: record.channel, @@ -499,7 +652,12 @@ export class TradingPortfolioStreamManager { subscriptionId: record.subscriptionId, clientSubscriptionId: record.clientSubscriptionId, message, - }) + } + if (record.onError) { + record.onError(error, payload) + return + } + record.socket?.emit('trading-portfolio-error', payload) }) } @@ -521,11 +679,13 @@ export class TradingPortfolioStreamManager { } const providerId = payload.provider?.trim() + const workspaceId = payload.workspaceId?.trim() const serviceId = payload.serviceId?.trim() const portfolioIdentity = toPortfolioValueObject(payload.portfolioIdentity) const matches: TradingPortfolioSubscriptionRecord[] = [] socketMap.forEach((record) => { if (providerId && record.provider !== providerId) return + if (workspaceId && record.workspaceId !== workspaceId) return if (serviceId && record.serviceId !== serviceId) return if (payload.channel && record.channel !== payload.channel) return if ( @@ -540,11 +700,13 @@ export class TradingPortfolioStreamManager { } private removeRecord(record: TradingPortfolioSubscriptionRecord) { - const socketMap = this.socketSubscriptions.get(record.socketId) - if (socketMap) { - socketMap.delete(record.subscriptionId) - if (socketMap.size === 0) { - this.socketSubscriptions.delete(record.socketId) + if (record.socketId) { + const socketMap = this.socketSubscriptions.get(record.socketId) + if (socketMap) { + socketMap.delete(record.subscriptionId) + if (socketMap.size === 0) { + this.socketSubscriptions.delete(record.socketId) + } } } @@ -557,11 +719,13 @@ export class TradingPortfolioStreamManager { clearInterval(streamState.pollingTimer) } this.streams.delete(record.streamKey) + } else { + this.ensurePolling(streamState, false) } logger.info('Trading portfolio subscription removed', { socketId: record.socketId, - userId: record.socket.userId, + userId: record.socket?.userId, provider: record.provider, serviceId: record.serviceId, portfolioIdentity: redactPortfolioIdentity(record.portfolioIdentity), @@ -576,39 +740,38 @@ export const tradingPortfolioStreamManager = new TradingPortfolioStreamManager() async function resolveTradingPortfolioContext( streamState: TradingPortfolioStreamState ): Promise { - const providerDefinition = getTradingProviderDefinition(streamState.providerId) - if (!providerDefinition) throw new Error('Unsupported trading provider') - - const serviceId = getTradingProviderOAuthServiceId(streamState.providerId, streamState.serviceId) - if (!serviceId) throw new Error('Trading provider OAuth service is not configured') - - const tokenAccountId = streamState.portfolioIdentity?.tokenAccountId - if (!tokenAccountId) throw new Error('portfolioIdentity token account is required') + const credentialId = streamState.portfolioIdentity?.credentialId + if (!credentialId) throw new Error('portfolioIdentity credential is required') + const workspaceId = streamState.workspaceId + if (!workspaceId) throw new Error('workspaceId is required') + const serviceId = streamState.serviceId + if (!serviceId) throw new Error('Trading provider connection is required') + + const workspaceAccess = await checkWorkspaceAccess(workspaceId, streamState.userId) + if (!workspaceAccess.exists || !workspaceAccess.hasAccess) { + throw new Error('Trading portfolio workspace not found') + } - const connectionAccount = await resolveOAuthConnectionAccountForUser({ - accountId: tokenAccountId, + const connection = await authorizeTradingConnectionRequest({ + credentialId, userId: streamState.userId, }) - if (!connectionAccount) throw new Error('Trading provider connection not found') - if (connectionAccount.providerId !== serviceId) { - throw new Error('Trading provider connection does not match requested service') - } - - const accessToken = await refreshAccessTokenIfNeeded( - connectionAccount.tokenAccountId, - connectionAccount.credentialOwnerUserId, - streamState.streamKey - ) - if (!accessToken) throw new Error('Trading provider connection not found') - const environment = getTradingProviderOAuthEnvironment(streamState.providerId, serviceId) - if (!environment) throw new Error('Trading provider connection is not configured') + const context = await resolveTradingProviderContext({ + requestData: { + provider: streamState.providerId, + credentialId, + serviceId, + }, + requestId: streamState.streamKey, + userId: streamState.userId, + connectionOwnerUserId: connection.connectionOwnerUserId, + tokenAccountId: connection.tokenAccountId, + accountProviderId: connection.accountProviderId, + }) return { + ...context, providerId: streamState.providerId, - tokenAccountId, - serviceId: serviceId, - environment, - accessToken, } } @@ -626,6 +789,12 @@ function resolveTradingProviderId( return providerId as TradingProviderId } +function resolveWorkspaceId(workspaceId: string | undefined, channel: TradingPortfolioChannel) { + const resolvedWorkspaceId = workspaceId?.trim() + if (!resolvedWorkspaceId && channel !== 'accounts') throw new Error('workspaceId is required') + return resolvedWorkspaceId +} + function resolveServiceId(providerId: TradingProviderId, serviceId?: string) { const resolvedServiceId = getTradingProviderOAuthServiceId(providerId, serviceId) if (!resolvedServiceId) throw new Error('Trading provider connection is required') @@ -644,6 +813,15 @@ function resolveChannel(channel?: TradingPortfolioChannel): TradingPortfolioChan throw new Error('Unsupported trading portfolio channel') } +function normalizePollIntervalMs(channel: TradingPortfolioChannel, pollIntervalSeconds?: number) { + const defaultIntervalMs = CHANNEL_POLL_INTERVAL_MS[channel] + if (typeof pollIntervalSeconds !== 'number' || !Number.isFinite(pollIntervalSeconds)) { + return defaultIntervalMs + } + + return Math.max(15_000, Math.min(3_600_000, Math.trunc(pollIntervalSeconds) * 1000)) +} + function resolvePortfolioIdentity( channel: TradingPortfolioChannel, payload: TradingPortfolioSubscribePayload, @@ -680,6 +858,7 @@ function resolvePerformanceWindow( function buildStreamKey(config: { userId: string + workspaceId?: string providerId: TradingProviderId serviceId?: string portfolioIdentity?: PortfolioIdentity @@ -690,6 +869,7 @@ function buildStreamKey(config: { .update( [ config.userId, + config.workspaceId ?? '', config.providerId, config.serviceId ?? '', config.channel, @@ -705,8 +885,10 @@ function buildAccountsCacheKey(streamState: TradingPortfolioStreamState) { .update( [ streamState.userId, + streamState.workspaceId ?? '', streamState.providerId, streamState.serviceId ?? '', + streamState.portfolioIdentity?.credentialId ?? '', ].join('|') ) .digest('hex') @@ -731,6 +913,7 @@ function toSubscriptionInfo( subscriptionId: record.subscriptionId, clientSubscriptionId: record.clientSubscriptionId, provider: record.provider, + workspaceId: record.workspaceId, serviceId: record.serviceId, portfolioIdentity: record.portfolioIdentity, channel: record.channel, diff --git a/apps/tradinggoose/socket-server/trading/portfolio-monitor-runtime.test.ts b/apps/tradinggoose/socket-server/trading/portfolio-monitor-runtime.test.ts new file mode 100644 index 000000000..65ac230b9 --- /dev/null +++ b/apps/tradinggoose/socket-server/trading/portfolio-monitor-runtime.test.ts @@ -0,0 +1,260 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + acquireLock: vi.fn(), + renewLock: vi.fn(), + releaseLock: vi.fn(), + getRedisClient: vi.fn(() => ({})), + getRedisStorageMode: vi.fn(() => 'redis'), + dbSelect: vi.fn(), + getApiKeyOwnerUserId: vi.fn(), + enqueuePendingExecution: vi.fn(), + isPendingExecutionLimitError: vi.fn(() => false), + evaluatePortfolioFireCondition: vi.fn(() => true), +})) + +vi.mock('@tradinggoose/db', () => ({ + db: { + select: mocks.dbSelect, + }, + webhook: { + id: 'webhook.id', + provider: 'webhook.provider', + providerConfig: 'webhook.providerConfig', + isActive: 'webhook.isActive', + }, + workflow: { + id: 'workflow.id', + userId: 'workflow.userId', + workspaceId: 'workflow.workspaceId', + pinnedApiKeyId: 'workflow.pinnedApiKeyId', + isDeployed: 'workflow.isDeployed', + }, +})) + +vi.mock('drizzle-orm', () => ({ + and: vi.fn(() => 'and'), + eq: vi.fn(() => 'eq'), +})) + +vi.mock('@/lib/api-key/service', () => ({ + getApiKeyOwnerUserId: (...args: unknown[]) => mocks.getApiKeyOwnerUserId(...args), +})) + +vi.mock('@/lib/execution/execution-concurrency-limit', () => ({ + ExecutionGateError: class ExecutionGateError extends Error {}, +})) + +vi.mock('@/lib/execution/pending-execution', () => ({ + enqueuePendingExecution: (...args: unknown[]) => mocks.enqueuePendingExecution(...args), + isPendingExecutionLimitError: () => mocks.isPendingExecutionLimitError(), +})) + +vi.mock('@/lib/monitors/portfolio-conditions', () => ({ + evaluatePortfolioFireCondition: () => mocks.evaluatePortfolioFireCondition(), +})) + +vi.mock('@/lib/monitors/portfolio-config', () => ({ + PortfolioMonitorProviderConfigSchema: { + safeParse: (value: unknown) => ({ success: true, data: value }), + }, +})) + +vi.mock('@/lib/monitors/sources', () => ({ + PORTFOLIO_MONITOR_PROVIDER: 'portfolio_trigger', +})) + +vi.mock('@/lib/redis', () => ({ + acquireLock: (...args: unknown[]) => mocks.acquireLock(...args), + renewLock: (...args: unknown[]) => mocks.renewLock(...args), + releaseLock: (...args: unknown[]) => mocks.releaseLock(...args), + getRedisClient: () => mocks.getRedisClient(), + getRedisStorageMode: () => mocks.getRedisStorageMode(), +})) + +vi.mock('@/socket-server/trading/portfolio-manager', () => ({ + tradingPortfolioStreamManager: { + subscribeData: vi.fn(), + }, +})) + +import { PortfolioMonitorRuntime } from './portfolio-monitor-runtime' + +type PortfolioMonitorRuntimeInternals = { + subscriptions: Map< + string, + { + config: ReturnType + unsubscribe: () => void + } + > + updateRuntimeState: (monitorId: string, runtimeState: unknown) => Promise | void + handlePortfolioData: ( + monitorId: string, + payload: ReturnType + ) => Promise +} + +function buildEmptyMonitorQuery() { + return { + from: vi.fn(() => ({ + innerJoin: vi.fn(() => ({ + where: vi.fn().mockResolvedValue([]), + })), + })), + } +} + +function buildMonitorConfig() { + return { + id: 'monitor-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + connectionOwnerUserId: 'connection-owner-1', + pinnedApiKeyId: 'api-key-1', + blockId: 'block-1', + providerId: 'alpaca', + serviceId: 'alpaca-live', + credentialId: 'credential-1', + accountId: 'account-1', + condition: { combinator: 'and', rules: [] }, + fireMode: 'edge', + cooldownSeconds: 0, + pollIntervalSeconds: 30, + runtimeState: { + wasTrue: false, + }, + signature: 'signature-1', + } +} + +function buildPortfolioPayload() { + return { + provider: 'alpaca', + workspaceId: 'workspace-1', + serviceId: 'alpaca-live', + channel: 'account-snapshot', + portfolioIdentity: { + providerId: 'alpaca', + credentialId: 'credential-1', + serviceId: 'alpaca-live', + accountId: 'account-1', + }, + portfolioDetail: { + providerId: 'alpaca', + credentialId: 'credential-1', + serviceId: 'alpaca-live', + accountId: 'account-1', + }, + receivedAt: '2026-05-28T00:00:00.000Z', + } +} + +function attachRuntimeSubscription( + runtime: PortfolioMonitorRuntimeInternals, + config: ReturnType +) { + const updateRuntimeState = vi.fn() + runtime.subscriptions.set('monitor-1', { config, unsubscribe: vi.fn() }) + runtime.updateRuntimeState = updateRuntimeState + Object.assign(runtime, { getCurrentProviderConfig: vi.fn().mockResolvedValue({}) }) + return updateRuntimeState +} + +describe('PortfolioMonitorRuntime', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + mocks.acquireLock.mockResolvedValue(true) + mocks.renewLock.mockResolvedValue(true) + mocks.releaseLock.mockResolvedValue(true) + mocks.getRedisClient.mockReturnValue({}) + mocks.getRedisStorageMode.mockReturnValue('redis') + mocks.dbSelect.mockImplementation(() => buildEmptyMonitorQuery()) + mocks.getApiKeyOwnerUserId.mockResolvedValue('actor-1') + mocks.enqueuePendingExecution.mockResolvedValue({ + pendingExecutionId: 'pending-monitor-1', + billingScopeId: 'user-1', + inserted: true, + }) + mocks.isPendingExecutionLimitError.mockReturnValue(false) + mocks.evaluatePortfolioFireCondition.mockReturnValue(true) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('acquires and renews the portfolio runtime lock', async () => { + const runtime = new PortfolioMonitorRuntime({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }) + + await runtime.start() + + expect(mocks.acquireLock).toHaveBeenCalledWith( + 'portfolio-monitor-runtime-lock', + expect.any(String), + 90 + ) + expect(runtime.getHealth().status).toBe('running') + + await vi.advanceTimersByTimeAsync(30_000) + + expect(mocks.renewLock).toHaveBeenCalledWith( + 'portfolio-monitor-runtime-lock', + expect.any(String), + 90 + ) + + await runtime.stop() + expect(mocks.releaseLock).toHaveBeenCalledWith( + 'portfolio-monitor-runtime-lock', + expect.any(String) + ) + }) + + it('does not persist edge state when enqueue is rejected by backpressure', async () => { + const runtime = new PortfolioMonitorRuntime() as unknown as PortfolioMonitorRuntimeInternals + const config = buildMonitorConfig() + const updateRuntimeState = attachRuntimeSubscription(runtime, config) + const limitError = { + details: { + pendingCount: 100, + maxPendingCount: 100, + }, + } + + mocks.enqueuePendingExecution.mockRejectedValue(limitError) + mocks.isPendingExecutionLimitError.mockReturnValue(true) + + await runtime.handlePortfolioData('monitor-1', buildPortfolioPayload()) + + expect(mocks.enqueuePendingExecution).toHaveBeenCalledTimes(1) + expect(updateRuntimeState).not.toHaveBeenCalled() + expect(config.runtimeState).toEqual({ wasTrue: false }) + }) + + it('does not persist edge state when enqueue is deduped by ordering key', async () => { + const runtime = new PortfolioMonitorRuntime() as unknown as PortfolioMonitorRuntimeInternals + const config = buildMonitorConfig() + const updateRuntimeState = attachRuntimeSubscription(runtime, config) + + mocks.enqueuePendingExecution.mockResolvedValue({ + pendingExecutionId: 'pending-monitor-1', + billingScopeId: 'user-1', + inserted: false, + }) + + await runtime.handlePortfolioData('monitor-1', buildPortfolioPayload()) + + expect(mocks.enqueuePendingExecution).toHaveBeenCalledTimes(1) + expect(updateRuntimeState).not.toHaveBeenCalled() + expect(config.runtimeState).toEqual({ wasTrue: false }) + }) +}) diff --git a/apps/tradinggoose/socket-server/trading/portfolio-monitor-runtime.ts b/apps/tradinggoose/socket-server/trading/portfolio-monitor-runtime.ts new file mode 100644 index 000000000..7a659c69b --- /dev/null +++ b/apps/tradinggoose/socket-server/trading/portfolio-monitor-runtime.ts @@ -0,0 +1,578 @@ +import { randomUUID } from 'node:crypto' +import { db, webhook, workflow } from '@tradinggoose/db' +import { and, eq } from 'drizzle-orm' +import { getApiKeyOwnerUserId } from '@/lib/api-key/service' +import { ExecutionGateError } from '@/lib/execution/execution-concurrency-limit' +import { + enqueuePendingExecution, + isPendingExecutionLimitError, +} from '@/lib/execution/pending-execution' +import { createLogger } from '@/lib/logs/console/logger' +import { + evaluatePortfolioFireCondition, + type PortfolioConditionSnapshot, +} from '@/lib/monitors/portfolio-conditions' +import { + type PortfolioMonitorProviderConfig, + PortfolioMonitorProviderConfigSchema, +} from '@/lib/monitors/portfolio-config' +import { PORTFOLIO_MONITOR_PROVIDER } from '@/lib/monitors/sources' +import type { PortfolioMonitorExecutionPayload } from '@/background/portfolio-monitor-execution' +import type { PortfolioIdentity } from '@/providers/trading/portfolio-identity' +import { getTradingProviderOAuthServiceId } from '@/providers/trading/providers' +import type { TradingProviderId } from '@/providers/trading/types' +import { + createMonitorRuntimeLock, + getMonitorRuntimeUnavailableStatus, + isMonitorRuntimeDatabaseConnectionError, + type MonitorRuntimeLockHealth, + type MonitorRuntimeStatus, +} from '@/socket-server/monitor-runtime-lock' +import { + type TradingPortfolioDataPayload, + tradingPortfolioStreamManager, +} from '@/socket-server/trading/portfolio-manager' + +const logger = createLogger('PortfolioMonitorRuntime') + +const LOCK_KEY = 'portfolio-monitor-runtime-lock' +const RECONCILE_INTERVAL_MS = 30_000 + +export type PortfolioMonitorRuntimeHealth = { + enabled: boolean + status: MonitorRuntimeStatus + lock: MonitorRuntimeLockHealth + stats: { + activeSubscriptions: number + lastReconcileAt: string | null + lastReconcileError: string | null + } +} + +type LoggerLike = { + info: (message: string, ...args: unknown[]) => void + warn: (message: string, ...args: unknown[]) => void + error: (message: string, ...args: unknown[]) => void +} + +type PortfolioMonitorRuntimeConfig = { + id: string + workflowId: string + workspaceId: string + connectionOwnerUserId: string + pinnedApiKeyId: string | null + blockId: string + providerId: TradingProviderId + serviceId: string + credentialId: string + accountId: string + condition: PortfolioMonitorProviderConfig['monitor']['condition'] + fireMode: PortfolioMonitorProviderConfig['monitor']['fireMode'] + cooldownSeconds: number + pollIntervalSeconds: number + runtimeState?: PortfolioMonitorProviderConfig['runtimeState'] + updatedAt: Date + signature: string +} + +type PortfolioMonitorSubscription = { + config: PortfolioMonitorRuntimeConfig + unsubscribe: () => void +} + +const toConfig = ( + row: typeof webhook.$inferSelect, + workflowRow: { + workspaceId: string | null + pinnedApiKeyId: string | null + } +): PortfolioMonitorRuntimeConfig | null => { + if (!workflowRow.workspaceId) return null + const providerConfig = PortfolioMonitorProviderConfigSchema.safeParse(row.providerConfig) + if (!providerConfig.success) return null + + const monitor = providerConfig.data.monitor + const serviceId = getTradingProviderOAuthServiceId(monitor.providerId, monitor.serviceId) + if (!serviceId) return null + + const normalized: Omit = { + id: row.id, + workflowId: row.workflowId, + workspaceId: workflowRow.workspaceId, + connectionOwnerUserId: monitor.connectionOwnerUserId, + pinnedApiKeyId: workflowRow.pinnedApiKeyId, + blockId: monitor.triggerBlockId, + providerId: monitor.providerId, + serviceId, + credentialId: monitor.credentialId, + accountId: monitor.accountId, + condition: monitor.condition, + fireMode: monitor.fireMode, + cooldownSeconds: monitor.cooldownSeconds, + pollIntervalSeconds: monitor.pollIntervalSeconds, + runtimeState: providerConfig.data.runtimeState, + updatedAt: row.updatedAt, + } + + return { + ...normalized, + signature: JSON.stringify({ + ...normalized, + runtimeState: undefined, + }), + } +} + +const toPortfolioIdentity = (config: PortfolioMonitorRuntimeConfig): PortfolioIdentity => ({ + providerId: config.providerId, + credentialId: config.credentialId, + serviceId: config.serviceId, + accountId: config.accountId, +}) + +const isCooldownOpen = (lastFiredAt: string | undefined, cooldownSeconds: number) => { + if (!lastFiredAt || cooldownSeconds <= 0) return true + const lastFiredMs = Date.parse(lastFiredAt) + if (!Number.isFinite(lastFiredMs)) return true + return Date.now() - lastFiredMs >= cooldownSeconds * 1000 +} + +export class PortfolioMonitorRuntime { + private readonly logger: LoggerLike + private readonly runtimeLock: ReturnType + private status: MonitorRuntimeStatus = 'not_initialized' + private running = false + private starting = false + private reconcileTimer: ReturnType | null = null + private retryTimer: ReturnType | null = null + private isReconciling = false + private pendingReconcile = false + private lastReconcileAt: string | null = null + private lastReconcileError: string | null = null + private subscriptions = new Map() + + constructor(loggerLike?: LoggerLike) { + this.logger = loggerLike ?? logger + this.runtimeLock = createMonitorRuntimeLock({ + key: LOCK_KEY, + label: 'Portfolio monitor', + logger: this.logger, + onLost: (error) => this.enterDegradedState('lock', error, true), + }) + } + + getHealth(): PortfolioMonitorRuntimeHealth { + return { + enabled: this.running, + status: this.status, + lock: this.runtimeLock.getHealth(this.status), + stats: { + activeSubscriptions: this.subscriptions.size, + lastReconcileAt: this.lastReconcileAt, + lastReconcileError: this.lastReconcileError, + }, + } + } + + async start() { + if (this.running || this.starting) return + + this.starting = true + try { + this.clearRetryTimer() + + if (!(await this.runtimeLock.acquire())) { + this.running = false + this.status = getMonitorRuntimeUnavailableStatus() + this.logger.warn('Portfolio monitor runtime disabled; lock acquisition failed.') + this.scheduleRetry() + return + } + + this.running = true + this.status = 'running' + this.runtimeLock.stopRenewal() + this.clearReconcileTimer() + this.runtimeLock.startRenewal() + + await this.reconcile('startup') + + if (!this.running) return + } finally { + this.starting = false + } + + this.reconcileTimer = setInterval(() => void this.reconcile('interval'), RECONCILE_INTERVAL_MS) + this.reconcileTimer.unref?.() + } + + async stop() { + this.clearRetryTimer() + this.runtimeLock.stopRenewal() + this.clearReconcileTimer() + this.stopSubscriptions() + await this.runtimeLock.release() + this.running = false + this.starting = false + this.status = 'not_initialized' + } + + async requestReconcile() { + if (!this.running) { + await this.start() + if (!this.running) return + } + await this.reconcile('request') + } + + private clearRetryTimer() { + if (!this.retryTimer) return + clearTimeout(this.retryTimer) + this.retryTimer = null + } + + private clearReconcileTimer() { + if (!this.reconcileTimer) return + clearInterval(this.reconcileTimer) + this.reconcileTimer = null + } + + private stopSubscriptions() { + this.subscriptions.forEach((subscription) => subscription.unsubscribe()) + this.subscriptions.clear() + } + + private scheduleRetry() { + if (this.retryTimer) return + + this.retryTimer = setTimeout(() => { + this.retryTimer = null + void this.start() + }, RECONCILE_INTERVAL_MS) + this.retryTimer.unref?.() + } + + private async enterDegradedState( + reason: 'startup' | 'interval' | 'request' | 'lock', + error: unknown, + shouldLogWarning: boolean + ) { + this.lastReconcileError = error instanceof Error ? error.message : String(error) + this.status = 'degraded' + this.running = false + this.pendingReconcile = false + this.runtimeLock.stopRenewal() + this.clearReconcileTimer() + this.stopSubscriptions() + await this.runtimeLock.release() + this.scheduleRetry() + + if (shouldLogWarning) { + this.logger.warn('Portfolio monitor paused; runtime unavailable', { + reason, + error: + error instanceof Error + ? { + message: error.message, + stack: error.stack, + } + : error, + }) + } + } + + private async reconcile(reason: 'startup' | 'interval' | 'request') { + if (!this.running) return + + if (this.isReconciling) { + this.pendingReconcile = true + return + } + + this.isReconciling = true + this.lastReconcileError = null + + try { + await this.reconcileSubscriptions(reason) + } catch (error) { + this.lastReconcileError = error instanceof Error ? error.message : String(error) + if (isMonitorRuntimeDatabaseConnectionError(error)) { + await this.enterDegradedState(reason, error, this.subscriptions.size > 0) + return + } + + this.logger.error('Portfolio monitor reconcile failed', { + reason, + error, + }) + } finally { + this.isReconciling = false + if (this.pendingReconcile) { + this.pendingReconcile = false + void this.reconcile('request') + } + } + } + + private async reconcileSubscriptions(reason: 'startup' | 'interval' | 'request') { + const rows = await db + .select({ + webhook, + workflow: { + workspaceId: workflow.workspaceId, + pinnedApiKeyId: workflow.pinnedApiKeyId, + isDeployed: workflow.isDeployed, + }, + }) + .from(webhook) + .innerJoin(workflow, eq(webhook.workflowId, workflow.id)) + .where(and(eq(webhook.provider, PORTFOLIO_MONITOR_PROVIDER), eq(webhook.isActive, true))) + + const configs: PortfolioMonitorRuntimeConfig[] = [] + for (const row of rows) { + if (!row.workflow.isDeployed) { + await this.disconnect(row.webhook.id, 'workflow_not_deployed') + continue + } + const config = toConfig(row.webhook, row.workflow) + if (!config) { + await this.disconnect(row.webhook.id, 'invalid_monitor_config') + continue + } + configs.push(config) + } + + const nextIds = new Set(configs.map((config) => config.id)) + this.subscriptions.forEach((subscription, monitorId) => { + if (!nextIds.has(monitorId)) { + subscription.unsubscribe() + this.subscriptions.delete(monitorId) + } + }) + + for (const config of configs) { + const existing = this.subscriptions.get(config.id) + if (existing?.config.signature === config.signature) continue + if (existing) { + existing.unsubscribe() + this.subscriptions.delete(config.id) + } + + const subscription = tradingPortfolioStreamManager.subscribeData({ + userId: config.connectionOwnerUserId, + workspaceId: config.workspaceId, + provider: config.providerId, + serviceId: config.serviceId, + portfolioIdentity: toPortfolioIdentity(config), + channel: 'account-snapshot', + pollIntervalSeconds: config.pollIntervalSeconds, + clientSubscriptionId: `portfolio-monitor:${config.id}`, + onData: async (payload) => { + try { + await this.handlePortfolioData(config.id, payload) + } catch (error) { + if (isMonitorRuntimeDatabaseConnectionError(error)) { + void this.enterDegradedState('request', error, true) + return + } + this.logger.error('Portfolio monitor data handler failed', { + monitorId: config.id, + error, + }) + } + }, + onError: (error) => { + this.logger.warn('Portfolio monitor data subscription failed', { + monitorId: config.id, + error, + }) + }, + }) + this.subscriptions.set(config.id, { + config, + unsubscribe: subscription.unsubscribe, + }) + } + + this.logger.info('Portfolio monitor reconcile completed', { + reason, + totalRows: rows.length, + activeSubscriptions: this.subscriptions.size, + }) + this.lastReconcileAt = new Date().toISOString() + } + + private async handlePortfolioData(monitorId: string, payload: TradingPortfolioDataPayload) { + if (payload.channel !== 'account-snapshot') return + const subscription = this.subscriptions.get(monitorId) + if (!subscription) return + + const config = subscription.config + const currentDetail = payload.portfolioDetail + const currentSnapshot: PortfolioConditionSnapshot = { + summary: currentDetail.summary, + positions: currentDetail.positions, + } + const previousSnapshot = config.runtimeState?.previousSnapshot as + | PortfolioConditionSnapshot + | undefined + const previousWasTrue = config.runtimeState?.wasTrue + const previousLastFiredAt = config.runtimeState?.lastFiredAt + const conditionMatched = evaluatePortfolioFireCondition({ + condition: config.condition, + current: currentSnapshot, + previous: previousSnapshot, + }) + const crossedEdge = conditionMatched && previousWasTrue !== true + const shouldFire = + conditionMatched && + (config.fireMode === 'while_true' || crossedEdge) && + isCooldownOpen(previousLastFiredAt, config.cooldownSeconds) + const evaluatedAt = new Date().toISOString() + const evaluatedState: PortfolioMonitorProviderConfig['runtimeState'] = { + lastEvaluatedAt: evaluatedAt, + lastFiredAt: previousLastFiredAt, + wasTrue: conditionMatched, + previousSnapshot: currentSnapshot, + } + + if (!shouldFire) { + if (await this.updateRuntimeState(config, evaluatedState)) { + config.runtimeState = evaluatedState + } + return + } + + if (!(await this.getCurrentProviderConfig(config))) return + + const actorUserId = await getApiKeyOwnerUserId(config.pinnedApiKeyId) + if (!actorUserId) { + await this.disconnect(config.id, 'missing_billing_actor') + return + } + + const pendingExecutionId = `monitor:${config.id}:${randomUUID()}` + const executionPayload: PortfolioMonitorExecutionPayload = { + source: PORTFOLIO_MONITOR_PROVIDER, + monitor: { + id: config.id, + workflowId: config.workflowId, + workspaceId: config.workspaceId, + actorUserId, + blockId: config.blockId, + providerId: config.providerId, + serviceId: config.serviceId, + credentialId: config.credentialId, + accountId: config.accountId, + condition: config.condition, + }, + portfolioIdentity: payload.portfolioIdentity, + portfolioDetail: currentDetail, + } + + try { + const handle = await enqueuePendingExecution({ + executionType: 'monitor', + pendingExecutionId, + workflowId: config.workflowId, + workspaceId: config.workspaceId, + userId: actorUserId, + source: 'monitor:portfolio', + orderingKey: `monitor:${config.id}`, + requestId: pendingExecutionId, + payload: executionPayload as unknown as Record, + }) + if (!handle.inserted) return + } catch (error) { + if (error instanceof ExecutionGateError) { + await this.disconnect(config.id, 'invalid_billing_context') + return + } + + if (isPendingExecutionLimitError(error)) { + this.logger.warn( + 'Portfolio monitor queue backlog is full; retaining monitor edge for retry', + { + monitorId: config.id, + pendingCount: error.details.pendingCount, + maxPendingCount: error.details.maxPendingCount, + } + ) + return + } + throw error + } + + const firedState = { + ...evaluatedState, + lastFiredAt: evaluatedAt, + } + if (await this.updateRuntimeState(config, firedState)) { + config.runtimeState = firedState + } + } + + private async getCurrentProviderConfig(config: PortfolioMonitorRuntimeConfig) { + const [row] = await db + .select({ + webhook, + workflow: { + workspaceId: workflow.workspaceId, + pinnedApiKeyId: workflow.pinnedApiKeyId, + }, + }) + .from(webhook) + .innerJoin(workflow, eq(webhook.workflowId, workflow.id)) + .where( + and( + eq(webhook.id, config.id), + eq(webhook.provider, PORTFOLIO_MONITOR_PROVIDER), + eq(webhook.isActive, true), + eq(workflow.isDeployed, true) + ) + ) + .limit(1) + if (!row) return null + const currentConfig = toConfig(row.webhook, row.workflow) + if (currentConfig?.signature !== config.signature) return null + return row.webhook.providerConfig as Record + } + + private async updateRuntimeState( + config: PortfolioMonitorRuntimeConfig, + runtimeState: PortfolioMonitorProviderConfig['runtimeState'] + ) { + const currentProviderConfig = await this.getCurrentProviderConfig(config) + if (!currentProviderConfig) return false + + const providerConfig = { + ...currentProviderConfig, + runtimeState, + } + const updated = await db + .update(webhook) + .set({ providerConfig }) + .where( + and( + eq(webhook.id, config.id), + eq(webhook.provider, PORTFOLIO_MONITOR_PROVIDER), + eq(webhook.updatedAt, config.updatedAt) + ) + ) + .returning({ id: webhook.id }) + return updated.length > 0 + } + + private async disconnect(monitorId: string, reason: string) { + const subscription = this.subscriptions.get(monitorId) + if (subscription) { + subscription.unsubscribe() + this.subscriptions.delete(monitorId) + } + await db + .update(webhook) + .set({ isActive: false, updatedAt: new Date() }) + .where(and(eq(webhook.id, monitorId), eq(webhook.provider, PORTFOLIO_MONITOR_PROVIDER))) + this.logger.warn('Portfolio monitor disconnected', { monitorId, reason }) + } +} diff --git a/apps/tradinggoose/tools/params.ts b/apps/tradinggoose/tools/params.ts index 011cfe74f..1d9395fcb 100644 --- a/apps/tradinggoose/tools/params.ts +++ b/apps/tradinggoose/tools/params.ts @@ -210,6 +210,8 @@ export function getToolParametersConfig( serviceId: subBlock.serviceId, requiredScopes: subBlock.requiredScopes, providerType: subBlock.providerType, + marketProviderKind: subBlock.marketProviderKind, + tradingProviderKind: subBlock.tradingProviderKind, tradingProviderFieldId: subBlock.tradingProviderFieldId, enableSearch: subBlock.enableSearch, searchPlaceholder: subBlock.searchPlaceholder, diff --git a/apps/tradinggoose/tools/registry.ts b/apps/tradinggoose/tools/registry.ts index c98130b3f..5030060d3 100644 --- a/apps/tradinggoose/tools/registry.ts +++ b/apps/tradinggoose/tools/registry.ts @@ -243,8 +243,8 @@ import { thinkingTool } from '@/tools/thinking' import { orderHistoryTool, tradingActionTool, - tradingHoldingsTool, tradingOrderDetailTool, + tradingPortfolioDetailTool, } from '@/tools/trading' import { sendSMSTool } from '@/tools/twilio' import { typeformFilesTool, typeformInsightsTool, typeformResponsesTool } from '@/tools/typeform' @@ -460,7 +460,7 @@ export const tools: Record = { mistral_parser: mistralParserTool, thinking_tool: thinkingTool, trading_place_order: tradingActionTool, - trading_get_holdings: tradingHoldingsTool, + trading_get_portfolio_detail: tradingPortfolioDetailTool, trading_order_detail: tradingOrderDetailTool, trading_order_history: orderHistoryTool, watchlist_read_lists: watchlistReadListsTool, diff --git a/apps/tradinggoose/tools/trading/action.test.ts b/apps/tradinggoose/tools/trading/action.test.ts index 6c1f47f6d..e11d7fd1a 100644 --- a/apps/tradinggoose/tools/trading/action.test.ts +++ b/apps/tradinggoose/tools/trading/action.test.ts @@ -3,13 +3,13 @@ import { tradingActionTool } from '@/tools/trading/action' const portfolioIdentity = { providerId: 'alpaca' as const, - tokenAccountId: 'oauth-account-1', + credentialId: 'oauth-account-1', serviceId: 'alpaca-live', accountId: 'ACC-1', } const tradierPortfolioIdentity = { providerId: 'tradier' as const, - tokenAccountId: 'oauth-account-2', + credentialId: 'oauth-account-2', serviceId: 'tradier-live', accountId: 'ACC-2', } diff --git a/apps/tradinggoose/tools/trading/index.ts b/apps/tradinggoose/tools/trading/index.ts index 5392fe4ae..b5c85b939 100644 --- a/apps/tradinggoose/tools/trading/index.ts +++ b/apps/tradinggoose/tools/trading/index.ts @@ -1,6 +1,6 @@ import { tradingActionTool } from '@/tools/trading/action' -import { tradingHoldingsTool } from '@/tools/trading/holdings' import { tradingOrderDetailTool } from '@/tools/trading/order_detail' import { orderHistoryTool } from '@/tools/trading/order_history' +import { tradingPortfolioDetailTool } from '@/tools/trading/portfolio-detail' -export { tradingActionTool, tradingHoldingsTool, tradingOrderDetailTool, orderHistoryTool } +export { tradingActionTool, tradingPortfolioDetailTool, tradingOrderDetailTool, orderHistoryTool } diff --git a/apps/tradinggoose/tools/trading/holdings.test.ts b/apps/tradinggoose/tools/trading/portfolio-detail.test.ts similarity index 72% rename from apps/tradinggoose/tools/trading/holdings.test.ts rename to apps/tradinggoose/tools/trading/portfolio-detail.test.ts index fd336b2f6..4e89c5062 100644 --- a/apps/tradinggoose/tools/trading/holdings.test.ts +++ b/apps/tradinggoose/tools/trading/portfolio-detail.test.ts @@ -4,6 +4,7 @@ const getPortfolioDetailMock = vi.fn() const authorizeTradingConnectionRequestMock = vi.fn() const resolveTradingProviderContextMock = vi.fn() const resolveTradingProviderSelectedAccountMock = vi.fn() +const checkWorkspaceAccessMock = vi.fn() vi.mock('@/providers/trading/portfolio', () => ({ getPortfolioDetail: (...args: unknown[]) => getPortfolioDetailMock(...args), @@ -17,27 +18,34 @@ vi.mock('@/lib/trading/context', () => ({ resolveTradingProviderSelectedAccountMock(...args), })) -import { getTradingHoldings } from '@/lib/trading/holdings' -import { tradingHoldingsTool } from '@/tools/trading/holdings' +vi.mock('@/lib/permissions/utils', () => ({ + checkWorkspaceAccess: (...args: unknown[]) => checkWorkspaceAccessMock(...args), +})) + +import { getTradingPortfolioDetail } from '@/lib/trading/portfolio-detail' +import { tradingPortfolioDetailTool } from '@/tools/trading/portfolio-detail' const portfolioIdentity = { providerId: 'tradier', - tokenAccountId: 'oauth-account-1', + credentialId: 'oauth-credential-1', serviceId: 'tradier-live', accountId: 'ACC-2', } -describe('tradingHoldingsTool', () => { +describe('tradingPortfolioDetailTool', () => { beforeEach(() => { vi.clearAllMocks() + checkWorkspaceAccessMock.mockResolvedValue({ exists: true, hasAccess: true }) getPortfolioDetailMock.mockResolvedValue({ accountId: 'ACC-2' }) authorizeTradingConnectionRequestMock.mockResolvedValue({ connectionOwnerUserId: 'user-1', + tokenAccountId: 'oauth-account-1', accountProviderId: 'tradier-live', }) resolveTradingProviderContextMock.mockResolvedValue({ requestId: 'request-1', providerId: 'tradier', + credentialId: 'oauth-credential-1', tokenAccountId: 'oauth-account-1', serviceId: 'tradier-live', environment: 'live', @@ -50,9 +58,10 @@ describe('tradingHoldingsTool', () => { }) }) - it('fetches holdings for the selected portfolioIdentity account', async () => { - const result = await getTradingHoldings({ + it('fetches portfolio detail for the selected portfolioIdentity account', async () => { + const result = await getTradingPortfolioDetail({ requestData: { + workspaceId: 'workspace-1', portfolioIdentity, }, requestId: 'request-1', @@ -61,21 +70,23 @@ describe('tradingHoldingsTool', () => { expect(result).toMatchObject({ provider: 'tradier', - holdings: { accountId: 'ACC-2' }, + portfolioDetail: { accountId: 'ACC-2' }, }) expect(resolveTradingProviderContextMock).toHaveBeenCalledWith({ requestData: { provider: 'tradier', - tokenAccountId: 'oauth-account-1', + credentialId: 'oauth-credential-1', serviceId: 'tradier-live', }, requestId: 'request-1', userId: 'user-1', connectionOwnerUserId: 'user-1', + tokenAccountId: 'oauth-account-1', accountProviderId: 'tradier-live', }) expect(getPortfolioDetailMock).toHaveBeenCalledWith({ providerId: 'tradier', + credentialId: 'oauth-credential-1', tokenAccountId: 'oauth-account-1', serviceId: 'tradier-live', environment: 'live', @@ -84,9 +95,9 @@ describe('tradingHoldingsTool', () => { }) }) - it('sends only canonical holdings request data to the holdings route', () => { + it('sends only canonical portfolio detail request data to the portfolio detail route', () => { expect( - tradingHoldingsTool.request.body?.({ + tradingPortfolioDetailTool.request.body?.({ portfolioIdentity, }) ).toMatchObject({ @@ -95,7 +106,7 @@ describe('tradingHoldingsTool', () => { }) it('declares workspace read scope for tool execution', () => { - expect(tradingHoldingsTool.execution).toEqual({ + expect(tradingPortfolioDetailTool.execution).toEqual({ workspace: { required: true, access: 'read' }, }) }) @@ -104,8 +115,9 @@ describe('tradingHoldingsTool', () => { authorizeTradingConnectionRequestMock.mockRejectedValue(new Error('Unauthorized')) await expect( - getTradingHoldings({ + getTradingPortfolioDetail({ requestData: { + workspaceId: 'workspace-1', portfolioIdentity, }, requestId: 'request-1', @@ -114,7 +126,7 @@ describe('tradingHoldingsTool', () => { ).rejects.toThrow('Unauthorized') expect(authorizeTradingConnectionRequestMock).toHaveBeenCalledWith({ - tokenAccountId: 'oauth-account-1', + credentialId: 'oauth-credential-1', userId: 'user-1', }) expect(resolveTradingProviderContextMock).not.toHaveBeenCalled() diff --git a/apps/tradinggoose/tools/trading/holdings.ts b/apps/tradinggoose/tools/trading/portfolio-detail.ts similarity index 59% rename from apps/tradinggoose/tools/trading/holdings.ts rename to apps/tradinggoose/tools/trading/portfolio-detail.ts index 3a29529d1..e9786d2b4 100644 --- a/apps/tradinggoose/tools/trading/holdings.ts +++ b/apps/tradinggoose/tools/trading/portfolio-detail.ts @@ -1,11 +1,14 @@ -import type { TradingHoldingsRequest } from '@/lib/trading/holdings' -import type { TradingHoldingsResponse } from '@/providers/trading/types' +import type { TradingPortfolioDetailRequest } from '@/lib/trading/portfolio-detail' +import type { TradingPortfolioDetailResponse } from '@/providers/trading/types' import type { ToolConfig } from '@/tools/types' -export const tradingHoldingsTool: ToolConfig = { - id: 'trading_get_holdings', - name: 'Trading: Get Holdings', - description: 'Fetch canonical portfolio detail from Alpaca or Tradier.', +export const tradingPortfolioDetailTool: ToolConfig< + TradingPortfolioDetailRequest, + TradingPortfolioDetailResponse +> = { + id: 'trading_get_portfolio_detail', + name: 'Trading: Get Portfolio Detail', + description: 'Fetch account summary, cash, positions, and orders from Alpaca or Tradier.', version: '1.0.0', execution: { workspace: { required: true, access: 'read' }, @@ -21,7 +24,7 @@ export const tradingHoldingsTool: ToolConfig ({ 'Content-Type': 'application/json', @@ -31,7 +34,7 @@ export const tradingHoldingsTool: ToolConfig => { + transformResponse: async (response): Promise => { const result = await response.json() return { success: true, @@ -40,9 +43,9 @@ export const tradingHoldingsTool: ToolConfig) => + createElement(BriefcaseBusiness, props) + +export const PortfolioStateTriggerBlock: BlockConfig = { + type: 'portfolio_state_trigger', + name: 'Portfolio Monitor', + description: + 'Trigger workflow from portfolio monitor events managed in /workspace/[workspaceId]/monitor.', + category: 'triggers', + icon: PortfolioStateTriggerIcon, + bgColor: '#2563EB', + bestPractices: ` + - Configure and manage monitors in /workspace/[workspaceId]/monitor. + - Use this trigger block to expose portfolio snapshot and matched condition fields. + - Keep broker credential, account, and fire-condition settings out of workflow trigger subblocks. + `, + subBlocks: [...(getTrigger('portfolio_state_trigger')?.subBlocks ?? [])], + tools: { + access: [], + }, + inputs: {}, + outputs: (getTrigger('portfolio_state_trigger')?.outputs ?? + {}) as unknown as BlockConfig['outputs'], + triggers: { + enabled: true, + available: ['portfolio_state_trigger'], + }, +} diff --git a/apps/tradinggoose/triggers/portfolio/trigger.ts b/apps/tradinggoose/triggers/portfolio/trigger.ts new file mode 100644 index 000000000..ac920c614 --- /dev/null +++ b/apps/tradinggoose/triggers/portfolio/trigger.ts @@ -0,0 +1,35 @@ +import type { TriggerConfig } from '@/triggers/types' + +export const portfolioStateTrigger: TriggerConfig = { + id: 'portfolio_state_trigger', + name: 'Portfolio State Trigger', + webhookProvider: 'portfolio', + description: 'Trigger workflow from portfolio monitor state changes', + version: '1.0.0', + subBlocks: [ + { + id: 'triggerInstructions', + title: 'Setup', + type: 'text', + mode: 'trigger', + defaultValue: + 'Portfolio monitors are managed from /workspace/[workspaceId]/monitor. Configure broker account, condition, and workflow target there.', + readOnly: true, + }, + ], + outputs: { + input: { type: 'string', description: 'Primary workflow text input.' }, + event: { type: 'string', description: 'Portfolio monitor event key.' }, + portfolio: { + identity: { type: 'object', description: 'Trading portfolio identity.' }, + detail: { type: 'object', description: 'Portfolio detail snapshot.' }, + }, + monitor: { + id: { type: 'string', description: 'Monitor id.' }, + providerId: { type: 'string', description: 'Trading provider id.' }, + serviceId: { type: 'string', description: 'Trading service id.' }, + accountId: { type: 'string', description: 'Trading account id.' }, + }, + condition: { type: 'json', description: 'Matched portfolio fire condition.' }, + }, +} diff --git a/apps/tradinggoose/triggers/registry.ts b/apps/tradinggoose/triggers/registry.ts index c4c3c469e..63ba45047 100644 --- a/apps/tradinggoose/triggers/registry.ts +++ b/apps/tradinggoose/triggers/registry.ts @@ -64,6 +64,7 @@ import { microsoftTeamsWebhookTrigger, } from '@/triggers/microsoftteams' import { outlookPollingTrigger } from '@/triggers/outlook' +import { portfolioStateTrigger } from '@/triggers/portfolio/trigger' import { rssPollingTrigger } from '@/triggers/rss' import { scheduleTrigger } from '@/triggers/schedule' import { slackWebhookTrigger } from '@/triggers/slack' @@ -146,5 +147,6 @@ export const TRIGGER_REGISTRY: TriggerRegistry = { hubspot_ticket_deleted: hubspotTicketDeletedTrigger, hubspot_ticket_property_changed: hubspotTicketPropertyChangedTrigger, indicator_trigger: indicatorTrigger, + portfolio_state_trigger: portfolioStateTrigger, imap_poller: imapPollingTrigger, } diff --git a/apps/tradinggoose/widgets/utils/heatmap-params.test.ts b/apps/tradinggoose/widgets/utils/heatmap-params.test.ts index 3ae0b54ba..f80935873 100644 --- a/apps/tradinggoose/widgets/utils/heatmap-params.test.ts +++ b/apps/tradinggoose/widgets/utils/heatmap-params.test.ts @@ -3,7 +3,7 @@ import { sanitizeHeatmapParams } from '@/widgets/utils/heatmap-params' const portfolioIdentity = { providerId: 'alpaca', - tokenAccountId: 'oauth-account-1', + credentialId: 'oauth-account-1', serviceId: 'alpaca-live', accountId: 'account-1', } diff --git a/apps/tradinggoose/widgets/utils/portfolio-snapshot-params.test.tsx b/apps/tradinggoose/widgets/utils/portfolio-snapshot-params.test.tsx index 1e41b18dd..3931706fd 100644 --- a/apps/tradinggoose/widgets/utils/portfolio-snapshot-params.test.tsx +++ b/apps/tradinggoose/widgets/utils/portfolio-snapshot-params.test.tsx @@ -13,7 +13,7 @@ import { const portfolioIdentity = { providerId: 'alpaca', - tokenAccountId: 'oauth-account-1', + credentialId: 'oauth-account-1', serviceId: 'alpaca-live', accountId: 'acct-1', } diff --git a/apps/tradinggoose/widgets/utils/quick-order-params.test.tsx b/apps/tradinggoose/widgets/utils/quick-order-params.test.tsx index 0b6ffccd5..8f62e7fa4 100644 --- a/apps/tradinggoose/widgets/utils/quick-order-params.test.tsx +++ b/apps/tradinggoose/widgets/utils/quick-order-params.test.tsx @@ -13,7 +13,7 @@ import { const portfolioIdentity = { providerId: 'alpaca', - tokenAccountId: 'oauth-account-1', + credentialId: 'oauth-account-1', serviceId: 'alpaca-live', accountId: 'acct-1', } diff --git a/apps/tradinggoose/widgets/utils/trading-widget-providers.test.ts b/apps/tradinggoose/widgets/utils/trading-widget-providers.test.ts index 4a6d3f7f1..c639070d5 100644 --- a/apps/tradinggoose/widgets/utils/trading-widget-providers.test.ts +++ b/apps/tradinggoose/widgets/utils/trading-widget-providers.test.ts @@ -6,7 +6,7 @@ import { describe('trading widget provider helpers', () => { it('filters provider options by availability and resolves invalid persisted providers', () => { - const options = getTradingWidgetProviderOptions('holdings', { + const options = getTradingWidgetProviderOptions('portfolioDetail', { 'alpaca-paper': true, 'tradier-live': false, }) diff --git a/apps/tradinggoose/widgets/widgets/components/custom-tool-dropdown.tsx b/apps/tradinggoose/widgets/widgets/components/custom-tool-dropdown.tsx index bb18e7057..a15b279b2 100644 --- a/apps/tradinggoose/widgets/widgets/components/custom-tool-dropdown.tsx +++ b/apps/tradinggoose/widgets/widgets/components/custom-tool-dropdown.tsx @@ -11,16 +11,16 @@ import { import { Input } from '@/components/ui/input' import { ScrollArea } from '@/components/ui/scroll-area' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { cn } from '@/lib/utils' -import { useCustomTools } from '@/hooks/queries/custom-tools' -import { useCustomToolsStore } from '@/stores/custom-tools/store' -import type { CustomToolDefinition } from '@/stores/custom-tools/types' import { widgetHeaderControlClassName, widgetHeaderMenuContentClassName, widgetHeaderMenuItemClassName, widgetHeaderMenuTextClassName, -} from '@/widgets/widgets/components/widget-header-control' +} from '@/components/widget-header-control' +import { cn } from '@/lib/utils' +import { useCustomTools } from '@/hooks/queries/custom-tools' +import { useCustomToolsStore } from '@/stores/custom-tools/store' +import type { CustomToolDefinition } from '@/stores/custom-tools/types' const DEFAULT_PLACEHOLDER = 'Select custom tool' const DROPDOWN_MAX_HEIGHT = '20rem' diff --git a/apps/tradinggoose/widgets/widgets/components/mcp-dropdown.tsx b/apps/tradinggoose/widgets/widgets/components/mcp-dropdown.tsx index 8dee30a7c..c1c834a83 100644 --- a/apps/tradinggoose/widgets/widgets/components/mcp-dropdown.tsx +++ b/apps/tradinggoose/widgets/widgets/components/mcp-dropdown.tsx @@ -12,15 +12,15 @@ import { import { Input } from '@/components/ui/input' import { ScrollArea } from '@/components/ui/scroll-area' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { cn } from '@/lib/utils' -import { useMcpServersStore } from '@/stores/mcp-servers/store' -import type { McpServerWithStatus } from '@/stores/mcp-servers/types' import { widgetHeaderControlClassName, widgetHeaderMenuContentClassName, widgetHeaderMenuItemClassName, widgetHeaderMenuTextClassName, -} from '@/widgets/widgets/components/widget-header-control' +} from '@/components/widget-header-control' +import { cn } from '@/lib/utils' +import { useMcpServersStore } from '@/stores/mcp-servers/store' +import type { McpServerWithStatus } from '@/stores/mcp-servers/types' const DEFAULT_PLACEHOLDER = 'Select MCP server' const DROPDOWN_MAX_HEIGHT = '20rem' diff --git a/apps/tradinggoose/widgets/widgets/components/pair-color-dropdown.tsx b/apps/tradinggoose/widgets/widgets/components/pair-color-dropdown.tsx index ec56f91f3..64aaf039f 100644 --- a/apps/tradinggoose/widgets/widgets/components/pair-color-dropdown.tsx +++ b/apps/tradinggoose/widgets/widgets/components/pair-color-dropdown.tsx @@ -7,14 +7,14 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { cn } from '@/lib/utils' -import { PAIR_COLOR_META, PAIR_COLOR_OPTIONS, type PairColor } from '@/widgets/pair-colors' import { widgetHeaderControlClassName, widgetHeaderMenuContentClassName, widgetHeaderMenuItemClassName, widgetHeaderMenuTextClassName, -} from '@/widgets/widgets/components/widget-header-control' +} from '@/components/widget-header-control' +import { cn } from '@/lib/utils' +import { PAIR_COLOR_META, PAIR_COLOR_OPTIONS, type PairColor } from '@/widgets/pair-colors' interface PairColorDropdownProps { color: PairColor diff --git a/apps/tradinggoose/widgets/widgets/components/pine-indicator-dropdown.tsx b/apps/tradinggoose/widgets/widgets/components/pine-indicator-dropdown.tsx index d25c22605..e1fe0cf7e 100644 --- a/apps/tradinggoose/widgets/widgets/components/pine-indicator-dropdown.tsx +++ b/apps/tradinggoose/widgets/widgets/components/pine-indicator-dropdown.tsx @@ -11,17 +11,17 @@ import { import { Input } from '@/components/ui/input' import { ScrollArea } from '@/components/ui/scroll-area' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { getStableVibrantColor } from '@/lib/colors' -import { DEFAULT_INDICATORS_META } from '@/lib/indicators/default' -import { cn } from '@/lib/utils' -import { useIndicators } from '@/hooks/queries/indicators' -import { useIndicatorsStore } from '@/stores/indicators/store' import { widgetHeaderControlClassName, widgetHeaderMenuContentClassName, widgetHeaderMenuItemClassName, widgetHeaderMenuTextClassName, -} from '@/widgets/widgets/components/widget-header-control' +} from '@/components/widget-header-control' +import { getStableVibrantColor } from '@/lib/colors' +import { DEFAULT_INDICATORS_META } from '@/lib/indicators/default' +import { cn } from '@/lib/utils' +import { useIndicators } from '@/hooks/queries/indicators' +import { useIndicatorsStore } from '@/stores/indicators/store' const DEFAULT_PLACEHOLDER = 'Select indicators' const FALLBACK_COLOR = '#3972F6' diff --git a/apps/tradinggoose/widgets/widgets/components/skill-dropdown.tsx b/apps/tradinggoose/widgets/widgets/components/skill-dropdown.tsx index 95dff5408..adb9272c5 100644 --- a/apps/tradinggoose/widgets/widgets/components/skill-dropdown.tsx +++ b/apps/tradinggoose/widgets/widgets/components/skill-dropdown.tsx @@ -11,15 +11,15 @@ import { import { Input } from '@/components/ui/input' import { ScrollArea } from '@/components/ui/scroll-area' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { cn } from '@/lib/utils' -import { useSkills } from '@/hooks/queries/skills' -import type { SkillDefinition } from '@/stores/skills/types' import { widgetHeaderControlClassName, widgetHeaderMenuContentClassName, widgetHeaderMenuItemClassName, widgetHeaderMenuTextClassName, -} from '@/widgets/widgets/components/widget-header-control' +} from '@/components/widget-header-control' +import { cn } from '@/lib/utils' +import { useSkills } from '@/hooks/queries/skills' +import type { SkillDefinition } from '@/stores/skills/types' const DEFAULT_PLACEHOLDER = 'Select skill' const DROPDOWN_MAX_HEIGHT = '20rem' diff --git a/apps/tradinggoose/widgets/widgets/components/trading-provider-selector.tsx b/apps/tradinggoose/widgets/widgets/components/trading-provider-selector.tsx deleted file mode 100644 index 9540d903d..000000000 --- a/apps/tradinggoose/widgets/widgets/components/trading-provider-selector.tsx +++ /dev/null @@ -1,156 +0,0 @@ -'use client' - -import { useMemo } from 'react' -import { Check, ChevronDown } from 'lucide-react' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { OAUTH_PROVIDERS, parseProvider } from '@/lib/oauth' -import { cn } from '@/lib/utils' -import { getTradingProviderDefinition } from '@/providers/trading/providers' -import { - widgetHeaderControlClassName, - widgetHeaderMenuContentClassName, - widgetHeaderMenuItemClassName, - widgetHeaderMenuTextClassName, -} from '@/widgets/widgets/components/widget-header-control' - -export type TradingProviderOption = { - id: string - name: string -} - -export const resolveTradingProviderIcon = (providerId?: string) => { - if (!providerId) { - return undefined - } - - const providerDefinition = getTradingProviderDefinition(providerId) - if (providerDefinition?.icon) { - return providerDefinition.icon - } - - const oauthProvider = providerDefinition?.oauth?.provider - if (!oauthProvider) { - return undefined - } - - return OAUTH_PROVIDERS[parseProvider(oauthProvider).baseProvider]?.icon -} - -type TradingProviderSelectorProps = { - value?: string | null - options: TradingProviderOption[] - onChange?: (providerId: string) => void - disabled?: boolean - placeholder?: string - triggerClassName?: string - menuClassName?: string -} - -const DEFAULT_PLACEHOLDER = 'Select Trading Provider' - -export function TradingProviderSelector({ - value, - options, - onChange, - disabled = false, - placeholder = DEFAULT_PLACEHOLDER, - triggerClassName, - menuClassName, -}: TradingProviderSelectorProps) { - const optionsWithIcons = useMemo( - () => - options.map((option) => ({ - ...option, - icon: resolveTradingProviderIcon(option.id), - })), - [options] - ) - const selectedOption = optionsWithIcons.find((option) => option.id === value) ?? null - const label = selectedOption ? `Broker: ${selectedOption.name}` : placeholder - const SelectedIcon = selectedOption?.icon - const isDropdownDisabled = disabled || optionsWithIcons.length === 0 - const tooltipText = isDropdownDisabled ? 'Provider selection unavailable' : 'Select broker' - - return ( - - - - - - - - - - {tooltipText} - - - {optionsWithIcons.length === 0 ? ( -
No providers
- ) : ( - optionsWithIcons.map((option) => { - const Icon = option.icon - const isSelected = option.id === value - - return ( - { - if (option.id === value) return - onChange?.(option.id) - }} - > - {Icon ? ( - - ) - }) - )} -
-
- ) -} diff --git a/apps/tradinggoose/widgets/widgets/components/use-portfolio-identity-selection.ts b/apps/tradinggoose/widgets/widgets/components/use-portfolio-identity-selection.ts index 0cbfcf837..22b3d656c 100644 --- a/apps/tradinggoose/widgets/widgets/components/use-portfolio-identity-selection.ts +++ b/apps/tradinggoose/widgets/widgets/components/use-portfolio-identity-selection.ts @@ -1,13 +1,13 @@ 'use client' import { useEffect, useMemo } from 'react' +import { useTradingServices } from '@/components/trading-selector/services' import { usePortfolioIdentities } from '@/hooks/queries/trading-portfolio' import { arePortfolioIdentitiesEqual, type PortfolioIdentity, toPortfolioValueObject, } from '@/providers/trading/portfolio-identity' -import { useTradingServices } from '@/widgets/widgets/components/trading-services' type EmitPortfolioParamsChange = (input: { params: Record diff --git a/apps/tradinggoose/widgets/widgets/components/widget-action-menu.tsx b/apps/tradinggoose/widgets/widgets/components/widget-action-menu.tsx index 2b504f0b5..e8c532e95 100644 --- a/apps/tradinggoose/widgets/widgets/components/widget-action-menu.tsx +++ b/apps/tradinggoose/widgets/widgets/components/widget-action-menu.tsx @@ -8,13 +8,13 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { cn } from '@/lib/utils' import { widgetHeaderIconButtonClassName, widgetHeaderMenuContentClassName, widgetHeaderMenuItemClassName, widgetHeaderMenuTextClassName, -} from '@/widgets/widgets/components/widget-header-control' +} from '@/components/widget-header-control' +import { cn } from '@/lib/utils' interface WidgetActionMenuProps { onSplitVertical?: () => void diff --git a/apps/tradinggoose/widgets/widgets/components/widget-header-refresh-button.tsx b/apps/tradinggoose/widgets/widgets/components/widget-header-refresh-button.tsx index a9df9f685..ee8488ec1 100644 --- a/apps/tradinggoose/widgets/widgets/components/widget-header-refresh-button.tsx +++ b/apps/tradinggoose/widgets/widgets/components/widget-header-refresh-button.tsx @@ -2,7 +2,7 @@ import { RefreshCw } from 'lucide-react' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { widgetHeaderIconButtonClassName } from '@/widgets/widgets/components/widget-header-control' +import { widgetHeaderIconButtonClassName } from '@/components/widget-header-control' type WidgetHeaderRefreshButtonProps = { disabled?: boolean diff --git a/apps/tradinggoose/widgets/widgets/components/widget-selector.tsx b/apps/tradinggoose/widgets/widgets/components/widget-selector.tsx index 42753102a..a6190542c 100644 --- a/apps/tradinggoose/widgets/widgets/components/widget-selector.tsx +++ b/apps/tradinggoose/widgets/widgets/components/widget-selector.tsx @@ -9,16 +9,16 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { cn } from '@/lib/utils' -import { getWidgetCategories, getWidgetDefinition } from '@/widgets/registry' -import type { DashboardWidgetDefinition } from '@/widgets/types' import { widgetHeaderControlClassName, widgetHeaderMenuContentClassName, widgetHeaderMenuIconClassName, widgetHeaderMenuItemClassName, widgetHeaderMenuTextClassName, -} from '@/widgets/widgets/components/widget-header-control' +} from '@/components/widget-header-control' +import { cn } from '@/lib/utils' +import { getWidgetCategories, getWidgetDefinition } from '@/widgets/registry' +import type { DashboardWidgetDefinition } from '@/widgets/types' export interface WidgetSelectorProps { currentKey?: string | null diff --git a/apps/tradinggoose/widgets/widgets/components/workflow-dropdown.tsx b/apps/tradinggoose/widgets/widgets/components/workflow-dropdown.tsx index a2e2ea344..464dcc2b4 100644 --- a/apps/tradinggoose/widgets/widgets/components/workflow-dropdown.tsx +++ b/apps/tradinggoose/widgets/widgets/components/workflow-dropdown.tsx @@ -12,18 +12,18 @@ import { import { Input } from '@/components/ui/input' import { ScrollArea } from '@/components/ui/scroll-area' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { + widgetHeaderControlClassName, + widgetHeaderMenuContentClassName, + widgetHeaderMenuItemClassName, + widgetHeaderMenuTextClassName, +} from '@/components/widget-header-control' import { cn } from '@/lib/utils' import { useSetPairColorContext } from '@/stores/dashboard/pair-store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' import { WORKSPACE_BOOTSTRAP_CHANNEL } from '@/stores/workflows/registry/types' import type { PairColor } from '@/widgets/pair-colors' -import { - widgetHeaderControlClassName, - widgetHeaderMenuContentClassName, - widgetHeaderMenuItemClassName, - widgetHeaderMenuTextClassName, -} from '@/widgets/widgets/components/widget-header-control' const DEFAULT_PLACEHOLDER = 'Select workflow' const DROPDOWN_MAX_HEIGHT = '20rem' diff --git a/apps/tradinggoose/widgets/widgets/copilot/components/copilot/copilot-header.tsx b/apps/tradinggoose/widgets/widgets/copilot/components/copilot/copilot-header.tsx index f4c7e9839..7ca3758d0 100644 --- a/apps/tradinggoose/widgets/widgets/copilot/components/copilot/copilot-header.tsx +++ b/apps/tradinggoose/widgets/widgets/copilot/components/copilot/copilot-header.tsx @@ -18,13 +18,13 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { ScrollArea } from '@/components/ui/scroll-area' -import { cn } from '@/lib/utils' -import { getCopilotStore } from '@/stores/copilot/store' -import type { CopilotChat } from '@/stores/copilot/types' import { widgetHeaderControlClassName, widgetHeaderIconButtonClassName, -} from '@/widgets/widgets/components/widget-header-control' +} from '@/components/widget-header-control' +import { cn } from '@/lib/utils' +import { getCopilotStore } from '@/stores/copilot/store' +import type { CopilotChat } from '@/stores/copilot/types' const formatRelativeTime = (value: Date | string | undefined) => { if (!value) return '' @@ -193,16 +193,11 @@ export function CopilotHeader({ const { currentChat, chats, isLoadingChats, isSendingMessage } = state const scopedChats = useMemo( - () => - (chats || []).filter( - (chat) => (chat.workspaceId ?? null) === (workspaceId ?? null) - ), + () => (chats || []).filter((chat) => (chat.workspaceId ?? null) === (workspaceId ?? null)), [chats, workspaceId] ) const scopedCurrentChat = - currentChat && (currentChat.workspaceId ?? null) === (workspaceId ?? null) - ? currentChat - : null + currentChat && (currentChat.workspaceId ?? null) === (workspaceId ?? null) ? currentChat : null const grouped = groupChats(scopedChats) const handleSelectChat = async (chat: CopilotChat) => { diff --git a/apps/tradinggoose/widgets/widgets/data_chart/components/chart-controls.tsx b/apps/tradinggoose/widgets/widgets/data_chart/components/chart-controls.tsx index ca9861b27..d753b65e0 100644 --- a/apps/tradinggoose/widgets/widgets/data_chart/components/chart-controls.tsx +++ b/apps/tradinggoose/widgets/widgets/data_chart/components/chart-controls.tsx @@ -8,16 +8,16 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { cn } from '@/lib/utils' -import type { MarketInterval } from '@/providers/market/types' -import { emitDataChartParamsChange } from '@/widgets/utils/chart-params' -import { IndicatorDropdown } from '@/widgets/widgets/components/pine-indicator-dropdown' import { widgetHeaderButtonGroupClassName, widgetHeaderIconButtonClassName, widgetHeaderMenuContentClassName, widgetHeaderMenuItemClassName, -} from '@/widgets/widgets/components/widget-header-control' +} from '@/components/widget-header-control' +import { cn } from '@/lib/utils' +import type { MarketInterval } from '@/providers/market/types' +import { emitDataChartParamsChange } from '@/widgets/utils/chart-params' +import { IndicatorDropdown } from '@/widgets/widgets/components/pine-indicator-dropdown' import { CANDLE_TYPE_OPTIONS } from '@/widgets/widgets/data_chart/options' import { formatIntervalLabel } from '@/widgets/widgets/data_chart/series-data' import type { DataChartWidgetParams } from '@/widgets/widgets/data_chart/types' diff --git a/apps/tradinggoose/widgets/widgets/data_chart/components/footer.tsx b/apps/tradinggoose/widgets/widgets/data_chart/components/footer.tsx index fbad4712e..f066042ff 100644 --- a/apps/tradinggoose/widgets/widgets/data_chart/components/footer.tsx +++ b/apps/tradinggoose/widgets/widgets/data_chart/components/footer.tsx @@ -12,18 +12,18 @@ import { import { Input } from '@/components/ui/input' import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { isUtcOffset, normalizeUtcOffset } from '@/lib/time-format' -import { cn } from '@/lib/utils' -import { getMarketSeriesCapabilities } from '@/providers/market/providers' -import type { MarketInterval, MarketRangeUnit } from '@/providers/market/types' -import { emitDataChartParamsChange } from '@/widgets/utils/chart-params' import { widgetHeaderControlClassName, widgetHeaderIconButtonClassName, widgetHeaderMenuContentClassName, widgetHeaderMenuItemClassName, widgetHeaderMenuTextClassName, -} from '@/widgets/widgets/components/widget-header-control' +} from '@/components/widget-header-control' +import { isUtcOffset, normalizeUtcOffset } from '@/lib/time-format' +import { cn } from '@/lib/utils' +import { getMarketSeriesCapabilities } from '@/providers/market/providers' +import type { MarketInterval, MarketRangeUnit } from '@/providers/market/types' +import { emitDataChartParamsChange } from '@/widgets/utils/chart-params' import { addRangeToDate, DEFAULT_RANGE_PRESETS, @@ -387,11 +387,11 @@ const DataChartNormalizationDropdown = ({ : 'Normalization unavailable' const handleNormalizationSelect = (nextMode: string | null) => { - const nextProviderParams = { ...(params.data?.providerParams ?? {}) } as Record + const { normalization_mode: _normalizationMode, ...nextProviderParamsBase } = (params.data + ?.providerParams ?? {}) as Record + const nextProviderParams = { ...nextProviderParamsBase } if (nextMode) { nextProviderParams.normalization_mode = nextMode - } else { - delete nextProviderParams.normalization_mode } emitDataChartParamsChange({ params: { @@ -496,10 +496,15 @@ export const DataChartFooter = ({ } = (params.data ?? {}) as Record const nextData = { ...nextDataBase } - const nextView = { ...(params.view ?? {}) } as Record - nextView.rangePresetId = preset.id - delete nextView.start - delete nextView.end + const { + start: _start, + end: _end, + ...nextViewBase + } = (params.view ?? {}) as Record + const nextView: Record = { + ...nextViewBase, + rangePresetId: preset.id, + } if (interval) { nextView.interval = interval } diff --git a/apps/tradinggoose/widgets/widgets/data_chart/components/listing-control.test.tsx b/apps/tradinggoose/widgets/widgets/data_chart/components/listing-control.test.tsx index df97cdb56..f59306f5b 100644 --- a/apps/tradinggoose/widgets/widgets/data_chart/components/listing-control.test.tsx +++ b/apps/tradinggoose/widgets/widgets/data_chart/components/listing-control.test.tsx @@ -48,7 +48,7 @@ vi.mock('@/components/listing-selector/listing/rank-updates', () => ({ triggerListingRankUpdate: vi.fn(), })) -vi.mock('@/widgets/widgets/components/widget-header-control', () => ({ +vi.mock('@/components/widget-header-control', () => ({ widgetHeaderControlClassName: (className?: string) => ['trigger', className].filter(Boolean).join(' '), })) @@ -119,10 +119,7 @@ describe('DataChartListingControl', () => { await act(async () => { if (!input) return - const valueSetter = Object.getOwnPropertyDescriptor( - HTMLInputElement.prototype, - 'value' - )?.set + const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set valueSetter?.call(input, 'M') input.dispatchEvent(new Event('input', { bubbles: true })) await Promise.resolve() diff --git a/apps/tradinggoose/widgets/widgets/data_chart/components/provider-controls.tsx b/apps/tradinggoose/widgets/widgets/data_chart/components/provider-controls.tsx index f36908c9c..6daf66b71 100644 --- a/apps/tradinggoose/widgets/widgets/data_chart/components/provider-controls.tsx +++ b/apps/tradinggoose/widgets/widgets/data_chart/components/provider-controls.tsx @@ -1,7 +1,7 @@ 'use client' +import { MarketProviderControls } from '@/components/market-selector/provider-controls' import { emitDataChartParamsChange } from '@/widgets/utils/chart-params' -import { MarketProviderControls } from '@/widgets/widgets/components/market-provider-controls' import { WidgetHeaderRefreshButton } from '@/widgets/widgets/components/widget-header-refresh-button' import { providerOptions } from '@/widgets/widgets/data_chart/options' import type { DataChartWidgetParams } from '@/widgets/widgets/data_chart/types' diff --git a/apps/tradinggoose/widgets/widgets/editor_custom_tool/index.tsx b/apps/tradinggoose/widgets/widgets/editor_custom_tool/index.tsx index c6af2bc0b..a804016ea 100644 --- a/apps/tradinggoose/widgets/widgets/editor_custom_tool/index.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_custom_tool/index.tsx @@ -5,6 +5,7 @@ import { Download, Save, SquareTerminal } from 'lucide-react' import { Button } from '@/components/ui/button' import { LoadingAgent } from '@/components/ui/loading-agent' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { widgetHeaderButtonGroupClassName } from '@/components/widget-header-control' import { useCustomTools } from '@/hooks/queries/custom-tools' import { useCustomToolsStore } from '@/stores/custom-tools/store' import type { CustomToolDefinition } from '@/stores/custom-tools/types' @@ -25,7 +26,6 @@ import { resolveCustomToolId, } from '@/widgets/widgets/_shared/custom_tool/utils' import { CustomToolDropdown } from '@/widgets/widgets/components/custom-tool-dropdown' -import { widgetHeaderButtonGroupClassName } from '@/widgets/widgets/components/widget-header-control' import { CustomToolEditor, type CustomToolEditorSection, @@ -136,7 +136,9 @@ function EditorCustomToolWidgetBody({ tools.some((tool) => tool.id === normalizedRequestedCustomToolId) const selectedToolId = hasRequestedTool ? normalizedRequestedCustomToolId - : (isLinkedToColorPair ? null : (tools[0]?.id ?? null)) + : isLinkedToColorPair + ? null + : (tools[0]?.id ?? null) useCustomToolSelectionPersistence({ onWidgetParamsChange, diff --git a/apps/tradinggoose/widgets/widgets/editor_mcp/index.tsx b/apps/tradinggoose/widgets/widgets/editor_mcp/index.tsx index 7614968fb..c2431006e 100644 --- a/apps/tradinggoose/widgets/widgets/editor_mcp/index.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_mcp/index.tsx @@ -1,6 +1,7 @@ 'use client' import { Play, RefreshCw, RotateCcw, Save, Server, X } from 'lucide-react' +import { widgetHeaderButtonGroupClassName } from '@/components/widget-header-control' import { usePairColorContext, useSetPairColorContext } from '@/stores/dashboard/pair-store' import type { PairColor } from '@/widgets/pair-colors' import type { DashboardWidgetDefinition } from '@/widgets/types' @@ -9,7 +10,6 @@ import { emitMcpSelectionChange } from '@/widgets/utils/mcp-selection' import { readEntitySelectionState, resolveMcpServerId } from '@/widgets/widgets/_shared/mcp/utils' import { EntityEditorHeaderButton } from '@/widgets/widgets/components/entity-editor-buttons' import { McpDropdown } from '@/widgets/widgets/components/mcp-dropdown' -import { widgetHeaderButtonGroupClassName } from '@/widgets/widgets/components/widget-header-control' import { EditorMcpWidgetBody } from '@/widgets/widgets/editor_mcp/editor-mcp-body' const McpEditorSelector = ({ @@ -82,9 +82,7 @@ const McpEditorHeaderActions = ({ }) const hasSelection = !!selectionState.selectedEntityId - const emitAction = ( - action: 'save' | 'refresh' | 'close' | 'reset' | 'test' - ) => { + const emitAction = (action: 'save' | 'refresh' | 'close' | 'reset' | 'test') => { emitMcpEditorAction({ action, panelId, diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/deployment-controls/deployment-controls.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/deployment-controls/deployment-controls.tsx index b19e595e0..1f4ebf813 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/deployment-controls/deployment-controls.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/deployment-controls/deployment-controls.tsx @@ -4,12 +4,12 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { Loader2, Rocket } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { widgetHeaderIconButtonClassName } from '@/components/widget-header-control' import { cn } from '@/lib/utils' -import { DeployModal } from '@/widgets/widgets/editor_workflow/components/control-bar/components' import type { WorkspaceUserPermissions } from '@/hooks/use-user-permissions' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowState } from '@/stores/workflows/workflow/types' -import { widgetHeaderIconButtonClassName } from '@/widgets/widgets/components/widget-header-control' +import { DeployModal } from '@/widgets/widgets/editor_workflow/components/control-bar/components' type ControlVariant = 'workspace' | 'widget' @@ -105,10 +105,9 @@ export function DeploymentControls({ 'hover:border-primary hover:bg-primary hover:text-black', 'transition-all duration-200', isDeployed && !isPreviousVersionActive && 'text-primary-hover', - isPreviousVersionActive && - 'border-primary bg-primary-hover/5 text-primary', + isPreviousVersionActive && 'border-primary bg-primary-hover/5 text-primary', isDisabled && - 'cursor-not-allowed opacity-50 hover:border hover:bg-card hover:text-card-foreground hover:shadow-xs' + 'cursor-not-allowed opacity-50 hover:border hover:bg-card hover:text-card-foreground hover:shadow-xs' )} > {isDeploying ? ( diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/export-controls/export-controls.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/export-controls/export-controls.tsx index 908ad5006..4dbec7059 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/export-controls/export-controls.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/export-controls/export-controls.tsx @@ -4,11 +4,11 @@ import { useState } from 'react' import { ArrowDownToLine } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { widgetHeaderIconButtonClassName } from '@/components/widget-header-control' import { createLogger } from '@/lib/logs/console/logger' import { useSkills } from '@/hooks/queries/skills' import { useWorkflowJsonStore } from '@/stores/workflows/json/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { widgetHeaderIconButtonClassName } from '@/widgets/widgets/components/widget-header-control' import { useWorkflowRoute } from '@/widgets/widgets/editor_workflow/context/workflow-route-context' const logger = createLogger('ExportControls') diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/control-bar.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/control-bar.tsx index c0967cc0d..7c9c0bfa0 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/control-bar.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/control-bar.tsx @@ -3,6 +3,10 @@ import { useEffect, useState } from 'react' import { LayoutDashboard, Play, RefreshCw, X } from 'lucide-react' import { Button, Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui' +import { + widgetHeaderButtonGroupClassName, + widgetHeaderIconButtonClassName, +} from '@/components/widget-header-control' import { useSession } from '@/lib/auth-client' import { createLogger } from '@/lib/logs/console/logger' import { cn } from '@/lib/utils' @@ -15,10 +19,6 @@ import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/provide import { useWorkflowExecution } from '@/hooks/workflow/use-workflow-execution' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowState } from '@/stores/workflows/workflow/types' -import { - widgetHeaderButtonGroupClassName, - widgetHeaderIconButtonClassName, -} from '@/widgets/widgets/components/widget-header-control' import { DeploymentControls, ExportControls, diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/credential-selector/credential-selector.tsx index a513453d1..89e9ee028 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/credential-selector/credential-selector.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/credential-selector/credential-selector.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { Check, ChevronDown, ExternalLink, RefreshCw } from 'lucide-react' +import { OAuthRequiredModal } from '@/components/oauth/oauth-required-modal' import { Button } from '@/components/ui/button' import { Command, @@ -25,7 +26,6 @@ import { parseProvider, } from '@/lib/oauth' import type { SubBlockConfig } from '@/blocks/types' -import { OAuthRequiredModal } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal' import { useSubBlockValue } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/hooks/use-sub-block-value' import { useWorkflowId } from '@/widgets/widgets/editor_workflow/context/workflow-route-context' diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/file-selector/components/confluence-file-selector.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/file-selector/components/confluence-file-selector.tsx index 154a53db8..24f5889a8 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/file-selector/components/confluence-file-selector.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/file-selector/components/confluence-file-selector.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { Check, ChevronDown, ExternalLink, RefreshCw, X } from 'lucide-react' import { ConfluenceIcon } from '@/components/icons/icons' +import { OAuthRequiredModal } from '@/components/oauth/oauth-required-modal' import { Button } from '@/components/ui/button' import { Command, @@ -20,7 +21,6 @@ import { getServiceIdFromScopes, type OAuthProvider, } from '@/lib/oauth' -import { OAuthRequiredModal } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal' const logger = createLogger('ConfluenceFileSelector') diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/file-selector/components/google-drive-picker.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/file-selector/components/google-drive-picker.tsx index 5d364fe7c..a83caea0c 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/file-selector/components/google-drive-picker.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/file-selector/components/google-drive-picker.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { Check, ChevronDown, ExternalLink, FileIcon, FolderIcon, RefreshCw, X } from 'lucide-react' import { GoogleDocsIcon, GoogleSheetsIcon } from '@/components/icons/icons' +import { OAuthRequiredModal } from '@/components/oauth/oauth-required-modal' import { Button } from '@/components/ui/button' import { Command, @@ -23,7 +24,6 @@ import { type OAuthProvider, parseProvider, } from '@/lib/oauth' -import { OAuthRequiredModal } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal' const logger = createLogger('GoogleDrivePicker') diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/file-selector/components/jira-issue-selector.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/file-selector/components/jira-issue-selector.tsx index 9915e391c..b5c64a9a3 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/file-selector/components/jira-issue-selector.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/file-selector/components/jira-issue-selector.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Check, ChevronDown, ExternalLink, RefreshCw, X } from 'lucide-react' import { JiraIcon } from '@/components/icons/icons' +import { OAuthRequiredModal } from '@/components/oauth/oauth-required-modal' import { Button } from '@/components/ui/button' import { Command, @@ -20,7 +21,6 @@ import { getServiceIdFromScopes, type OAuthProvider, } from '@/lib/oauth' -import { OAuthRequiredModal } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal' const logger = createLogger('JiraIssueSelector') diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/file-selector/components/microsoft-file-selector.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/file-selector/components/microsoft-file-selector.tsx index 84f014b02..8bab07049 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/file-selector/components/microsoft-file-selector.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/file-selector/components/microsoft-file-selector.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { Check, ChevronDown, ExternalLink, RefreshCw, X } from 'lucide-react' import { MicrosoftExcelIcon } from '@/components/icons/icons' +import { OAuthRequiredModal } from '@/components/oauth/oauth-required-modal' import { Button } from '@/components/ui/button' import { Command, @@ -23,7 +24,6 @@ import { type OAuthProvider, parseProvider, } from '@/lib/oauth' -import { OAuthRequiredModal } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal' import type { PlannerTask } from '@/tools/microsoft_planner/types' const logger = createLogger('MicrosoftFileSelector') diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/file-selector/components/teams-message-selector.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/file-selector/components/teams-message-selector.tsx index f128eaef4..768292355 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/file-selector/components/teams-message-selector.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/file-selector/components/teams-message-selector.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { Check, ChevronDown, ExternalLink, RefreshCw, X } from 'lucide-react' import { MicrosoftTeamsIcon } from '@/components/icons/icons' +import { OAuthRequiredModal } from '@/components/oauth/oauth-required-modal' import { Button } from '@/components/ui/button' import { Command, @@ -20,7 +21,6 @@ import { getServiceIdFromScopes, type OAuthProvider, } from '@/lib/oauth' -import { OAuthRequiredModal } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal' const logger = createLogger('TeamsMessageSelector') diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/file-selector/components/wealthbox-file-selector.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/file-selector/components/wealthbox-file-selector.tsx index 7ff287877..82a50fda1 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/file-selector/components/wealthbox-file-selector.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/file-selector/components/wealthbox-file-selector.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { Check, ChevronDown, RefreshCw, X } from 'lucide-react' import { WealthboxIcon } from '@/components/icons/icons' +import { OAuthRequiredModal } from '@/components/oauth/oauth-required-modal' import { Button } from '@/components/ui/button' import { Command, @@ -19,7 +20,6 @@ import { getServiceIdFromScopes, type OAuthProvider, } from '@/lib/oauth' -import { OAuthRequiredModal } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal' const logger = createLogger('WealthboxFileSelector') diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/folder-selector/folder-selector.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/folder-selector/folder-selector.tsx index 341773fdb..9faafb231 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/folder-selector/folder-selector.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/folder-selector/folder-selector.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { Check, ChevronDown, RefreshCw } from 'lucide-react' import { GmailIcon, OutlookIcon } from '@/components/icons/icons' +import { OAuthRequiredModal } from '@/components/oauth/oauth-required-modal' import { Button } from '@/components/ui/button' import { Command, @@ -15,7 +16,6 @@ import { import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { createLogger } from '@/lib/logs/console/logger' import { type Credential, getProviderIdFromServiceId, getServiceIdFromScopes } from '@/lib/oauth' -import { OAuthRequiredModal } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal' const logger = createLogger('FolderSelector') diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/project-selector/components/jira-project-selector.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/project-selector/components/jira-project-selector.tsx index 7778e437c..c748f1bbf 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/project-selector/components/jira-project-selector.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/project-selector/components/jira-project-selector.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Check, ChevronDown, ExternalLink, RefreshCw, X } from 'lucide-react' import { JiraIcon } from '@/components/icons/icons' +import { OAuthRequiredModal } from '@/components/oauth/oauth-required-modal' import { Button } from '@/components/ui/button' import { Command, @@ -20,7 +21,6 @@ import { getServiceIdFromScopes, type OAuthProvider, } from '@/lib/oauth' -import { OAuthRequiredModal } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal' const logger = createLogger('JiraProjectSelector') diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/tool-input/components/tool-credential-selector.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/tool-input/components/tool-credential-selector.tsx index 176b4c4b5..132fc6ea9 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/tool-input/components/tool-credential-selector.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/tool-input/components/tool-credential-selector.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from 'react' import { Check, ChevronDown, ExternalLink, Plus, RefreshCw } from 'lucide-react' +import { OAuthRequiredModal } from '@/components/oauth/oauth-required-modal' import { Button } from '@/components/ui/button' import { Command, @@ -17,7 +18,6 @@ import { type OAuthService, parseProvider, } from '@/lib/oauth' -import { OAuthRequiredModal } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal' import { useWorkflowId } from '@/widgets/widgets/editor_workflow/context/workflow-route-context' const logger = createLogger('ToolCredentialSelector') diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx index 0c343d2d9..5fba7f006 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx @@ -634,6 +634,8 @@ export function ToolInput({ blockId, subBlockId, isConnecting, disabled = false serviceId: uiComponent?.serviceId, requiredScopes: uiComponent?.requiredScopes, providerType, + marketProviderKind: uiComponent?.marketProviderKind, + tradingProviderKind: uiComponent?.tradingProviderKind, tradingProviderFieldId: uiComponent?.tradingProviderFieldId, enableSearch: uiComponent?.enableSearch, searchPlaceholder: uiComponent?.searchPlaceholder, diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/sub-block.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/sub-block.tsx index df666ceb9..d6342b94e 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/sub-block.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/sub-block.tsx @@ -2,7 +2,10 @@ import type React from 'react' import { memo, useEffect, useMemo, useRef, useState } from 'react' import { format } from 'date-fns' import { AlertTriangle, Info } from 'lucide-react' -import { Label, Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui' +import { MarketProviderSelector } from '@/components/market-selector/provider-selector' +import { TradingAccountSelector } from '@/components/trading-selector/account-selector' +import { TradingProviderSelector } from '@/components/trading-selector/provider-selector' +import { Label, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui' import { DateTimePicker } from '@/components/ui/datetime-picker' import { SimpleTimePicker } from '@/components/ui/simple-time-picker' import { Slider } from '@/components/ui/slider' @@ -15,6 +18,19 @@ import { } from '@/lib/time-format' import { cn } from '@/lib/utils' import type { SubBlockConfig } from '@/blocks/types' +import { useOAuthProviderAvailability } from '@/hooks/queries/oauth-provider-availability' +import { + getMarketProviderOptions, + getMarketProviderOptionsByKind, +} from '@/providers/market/providers' +import { + type PortfolioIdentity, + toPortfolioValueObject, +} from '@/providers/trading/portfolio-identity' +import { + getTradingWidgetProviderAvailabilityIds, + getTradingWidgetProviderOptions, +} from '@/widgets/utils/trading-widget-providers' import { ChannelSelectorInput, CheckboxList, @@ -51,6 +67,7 @@ import { } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components' import { DocumentTagEntry } from './components/document-tag-entry/document-tag-entry' import { KnowledgeTagFilters } from './components/knowledge-tag-filters/knowledge-tag-filters' +import { useDependsOnGate } from './hooks/use-depends-on-gate' import { useSubBlockValue } from './hooks/use-sub-block-value' interface SubBlockProps { @@ -237,6 +254,179 @@ function SubBlockDateTimeField({ ) } +const readSelectorValue = (value: unknown) => { + if (typeof value === 'string') return value.trim() + if (value && typeof value === 'object' && 'value' in value) { + const nestedValue = (value as { value?: unknown }).value + return typeof nestedValue === 'string' ? nestedValue.trim() : '' + } + return '' +} + +function useOptionValueSync( + value: string, + setValue: (value: string) => void, + options: Array<{ id: string }>, + disabled: boolean, + autoSelectFirstOption: boolean +) { + useEffect(() => { + if (disabled) return + + const optionIds = options.map((option) => option.id) + if (value && optionIds.includes(value)) return + + const nextValue = autoSelectFirstOption ? (optionIds[0] ?? '') : '' + if (value !== nextValue) { + setValue(nextValue) + } + }, [autoSelectFirstOption, disabled, options, setValue, value]) +} + +function SubBlockMarketProviderSelector({ + blockId, + config, + disabled, + contextValues, +}: { + blockId: string + config: SubBlockConfig + disabled: boolean + contextValues?: Record +}) { + const [value, setValue] = useSubBlockValue(blockId, config.id) + const { finalDisabled } = useDependsOnGate(blockId, config, { disabled, contextValues }) + const options = useMemo( + () => + config.marketProviderKind + ? getMarketProviderOptionsByKind(config.marketProviderKind) + : getMarketProviderOptions(), + [config.marketProviderKind] + ) + const selectedValue = readSelectorValue(value) + + useOptionValueSync( + selectedValue, + setValue, + options, + finalDisabled, + config.autoSelectFirstOption !== false + ) + + return ( + + + + ) +} + +function SubBlockTradingProviderSelector({ + blockId, + config, + disabled, + contextValues, +}: { + blockId: string + config: SubBlockConfig + disabled: boolean + contextValues?: Record +}) { + const kind = config.tradingProviderKind ?? 'order' + const [value, setValue] = useSubBlockValue(blockId, config.id) + const { finalDisabled } = useDependsOnGate(blockId, config, { disabled, contextValues }) + const availabilityIds = useMemo(() => getTradingWidgetProviderAvailabilityIds(kind), [kind]) + const availabilityQuery = useOAuthProviderAvailability(availabilityIds, !finalDisabled) + const options = useMemo( + () => + availabilityQuery.data ? getTradingWidgetProviderOptions(kind, availabilityQuery.data) : [], + [kind, availabilityQuery.data] + ) + const selectedValue = readSelectorValue(value) + + useOptionValueSync( + selectedValue, + setValue, + options, + finalDisabled || availabilityQuery.isLoading, + config.autoSelectFirstOption !== false + ) + + return ( + + + + ) +} + +function SubBlockTradingAccountSelector({ + blockId, + config, + disabled, + contextValues, +}: { + blockId: string + config: SubBlockConfig + disabled: boolean + contextValues?: Record +}) { + const providerFieldId = config.tradingProviderFieldId ?? 'provider' + const [value, setValue] = useSubBlockValue(blockId, config.id) + const [storeProviderValue] = useSubBlockValue(blockId, providerFieldId) + const [requestedServiceId, setRequestedServiceId] = useState(null) + const { finalDisabled } = useDependsOnGate(blockId, config, { disabled, contextValues }) + const providerId = + readSelectorValue(contextValues?.[providerFieldId]) || readSelectorValue(storeProviderValue) + const portfolioIdentity = useMemo(() => toPortfolioValueObject(value), [value]) + + useEffect(() => { + setRequestedServiceId(null) + }, [providerId]) + + useEffect(() => { + if (portfolioIdentity && portfolioIdentity.providerId !== providerId) { + setValue('') + } + }, [portfolioIdentity, providerId, setValue]) + + return ( + + { + setRequestedServiceId( + selection.serviceId ?? selection.portfolioIdentity?.serviceId ?? null + ) + const nextIdentity = selection.portfolioIdentity + ? toPortfolioValueObject(selection.portfolioIdentity) + : null + setValue(nextIdentity ?? '') + }} + variant='form' + /> + + ) +} + export const SubBlock = memo( function SubBlock({ blockId, @@ -412,6 +602,33 @@ export const SubBlock = memo( contextValues={contextValues} /> ) + case 'market-provider-selector': + return ( + + ) + case 'trading-provider-selector': + return ( + + ) + case 'trading-account-selector': + return ( + + ) case 'order-id-selector': return ( + Record< + | '--block-active-pulse-color' + | '--block-active-ring-color' + | '--block-hover-color', + string + > } > {/* Show debug indicator for pending blocks */} diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-controlbar/controlbar.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-controlbar/controlbar.tsx index 09ac2b104..e6cc1c5af 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-controlbar/controlbar.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-controlbar/controlbar.tsx @@ -2,14 +2,14 @@ import { useMemo } from 'react' import { TooltipProvider } from '@/components/ui/tooltip' -import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' -import { ControlBar } from '@/widgets/widgets/editor_workflow/components/control-bar/control-bar' +import { widgetHeaderControlClassName } from '@/components/widget-header-control' import { WorkflowSessionProvider } from '@/lib/yjs/workflow-session-host' -import { WorkflowRouteProvider } from '@/widgets/widgets/editor_workflow/context/workflow-route-context' +import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { widgetHeaderControlClassName } from '@/widgets/widgets/components/widget-header-control' import type { WidgetInstance } from '@/widgets/layout' import { isPairColor, type PairColor } from '@/widgets/pair-colors' +import { ControlBar } from '@/widgets/widgets/editor_workflow/components/control-bar/control-bar' +import { WorkflowRouteProvider } from '@/widgets/widgets/editor_workflow/context/workflow-route-context' const FALLBACK_TEXT_CLASS = widgetHeaderControlClassName('text-muted-foreground/80') @@ -55,10 +55,7 @@ export function WorkflowWidgetControlBar({ return ( - + , quantity: number) => ({ - symbol: { - base: listing.listing_id, - quote: 'USD', - assetClass: 'stock' as const, - active: true, - rank: 0, - listing, - }, + listingIdentity: listing, quantity, }) @@ -75,13 +68,7 @@ const createPortfolioDetailFromQuantities = ( createPortfolioDetail( quantities.map(({ symbol, quantity }) => { const listing = createPortfolioListing(symbol) - return { - ...createPortfolioPosition(listing, quantity), - symbol: { - ...createPortfolioPosition(listing, quantity).symbol, - base: symbol, - }, - } + return createPortfolioPosition(listing, quantity) }) ) @@ -427,6 +414,7 @@ describe('HeatmapWidgetBody', () => { }) expect(mockUsePortfolioDetail).toHaveBeenLastCalledWith({ + workspaceId: 'workspace-1', provider: 'alpaca', serviceId: 'alpaca-live', portfolioIdentity, diff --git a/apps/tradinggoose/widgets/widgets/heatmap/components/body.tsx b/apps/tradinggoose/widgets/widgets/heatmap/components/body.tsx index 454b4003e..ecfc9a339 100644 --- a/apps/tradinggoose/widgets/widgets/heatmap/components/body.tsx +++ b/apps/tradinggoose/widgets/widgets/heatmap/components/body.tsx @@ -8,8 +8,8 @@ import { useResolvedListings } from '@/hooks/queries/listing-resolution' import { useMarketQuoteSnapshots } from '@/hooks/queries/market-quote-snapshots' import { useOAuthProviderAvailability } from '@/hooks/queries/oauth-provider-availability' import { usePortfolioDetail } from '@/hooks/queries/trading-portfolio' -import { getPortfolioListingExposures } from '@/providers/trading/portfolio-selectors' import { useWatchlists } from '@/hooks/queries/watchlists' +import { getPortfolioListingExposures } from '@/providers/trading/portfolio-selectors' import { useSetPairColorContext } from '@/stores/dashboard/pair-store' import type { WidgetComponentProps } from '@/widgets/types' import { @@ -136,23 +136,19 @@ export function HeatmapWidgetBody({ }) }, [hasInvalidPersistedTradingProvider, panelId, widgetKey]) - const { - accountsQuery, - activeServiceId, - activePortfolioIdentity, - services, - portfolioIdentities, - } = usePortfolioIdentitySelection({ - providerId: tradingProviderId, - serviceId: widgetParams?.serviceId, - portfolioIdentity: widgetParams?.portfolioIdentity, - enabled: sourceMode === 'portfolio' && isTradingProviderReady, - panelId, - widgetKey, - emitParamsChange: emitHeatmapParamsChange, - }) + const { accountsQuery, activeServiceId, activePortfolioIdentity, services, portfolioIdentities } = + usePortfolioIdentitySelection({ + providerId: tradingProviderId, + serviceId: widgetParams?.serviceId, + portfolioIdentity: widgetParams?.portfolioIdentity, + enabled: sourceMode === 'portfolio' && isTradingProviderReady, + panelId, + widgetKey, + emitParamsChange: emitHeatmapParamsChange, + }) const snapshotQuery = usePortfolioDetail({ + workspaceId: workspaceId ?? undefined, provider: sourceMode === 'portfolio' && isTradingProviderReady ? tradingProviderId : undefined, serviceId: activeServiceId, portfolioIdentity: activePortfolioIdentity, diff --git a/apps/tradinggoose/widgets/widgets/heatmap/components/header.test.tsx b/apps/tradinggoose/widgets/widgets/heatmap/components/header.test.tsx index 7a8f9ad36..6bef48c00 100644 --- a/apps/tradinggoose/widgets/widgets/heatmap/components/header.test.tsx +++ b/apps/tradinggoose/widgets/widgets/heatmap/components/header.test.tsx @@ -19,7 +19,7 @@ type MockTradingAccountSelectorProps = { } const selectedPortfolioIdentity: PortfolioIdentity = { providerId: 'alpaca', - tokenAccountId: 'oauth-account-1', + credentialId: 'oauth-credential-1', serviceId: 'alpaca-paper', accountId: 'acct-1', } @@ -52,11 +52,11 @@ vi.mock('@/components/ui/tooltip', () => ({ TooltipContent: ({ children }: { children?: ReactNode }) => <>{children}, })) -vi.mock('@/widgets/widgets/components/market-provider-settings-button', () => ({ +vi.mock('@/components/market-selector/provider-settings-button', () => ({ MarketProviderSettingsButton: () => , })) -vi.mock('@/widgets/widgets/components/market-provider-selector', () => ({ +vi.mock('@/components/market-selector/provider-selector', () => ({ MarketProviderSelector: ({ value, onChange, @@ -70,7 +70,7 @@ vi.mock('@/widgets/widgets/components/market-provider-selector', () => ({ ), })) -vi.mock('@/widgets/widgets/components/trading-provider-selector', () => ({ +vi.mock('@/components/trading-selector/provider-selector', () => ({ TradingProviderSelector: ({ value, onChange, @@ -88,12 +88,12 @@ vi.mock('@/widgets/widgets/components/trading-provider-selector', () => ({ ), })) -vi.mock('@/widgets/widgets/components/trading-account-selector', () => ({ +vi.mock('@/components/trading-selector/account-selector', () => ({ TradingAccountSelector: (props: MockTradingAccountSelectorProps) => mockTradingAccountSelector(props), })) -vi.mock('@/widgets/widgets/components/widget-header-control', () => ({ +vi.mock('@/components/widget-header-control', () => ({ widgetHeaderButtonGroupClassName: (className?: string) => ['controls', className].filter(Boolean).join(' '), widgetHeaderIconButtonClassName: () => 'icon-button', diff --git a/apps/tradinggoose/widgets/widgets/heatmap/components/header.tsx b/apps/tradinggoose/widgets/widgets/heatmap/components/header.tsx index 0735e4e4c..b5f8713ca 100644 --- a/apps/tradinggoose/widgets/widgets/heatmap/components/header.tsx +++ b/apps/tradinggoose/widgets/widgets/heatmap/components/header.tsx @@ -1,13 +1,13 @@ 'use client' import { useMemo } from 'react' +import { MarketProviderControls } from '@/components/market-selector/provider-controls' +import { TradingProviderControls } from '@/components/trading-selector/provider-controls' import { Button } from '@/components/ui/button' +import { widgetHeaderButtonGroupClassName } from '@/components/widget-header-control' import { useOAuthProviderAvailability } from '@/hooks/queries/oauth-provider-availability' import type { DashboardWidgetDefinition } from '@/widgets/types' import { emitHeatmapParamsChange } from '@/widgets/utils/heatmap-params' -import { MarketProviderControls } from '@/widgets/widgets/components/market-provider-controls' -import { TradingProviderControls } from '@/widgets/widgets/components/trading-provider-controls' -import { widgetHeaderButtonGroupClassName } from '@/widgets/widgets/components/widget-header-control' import { WidgetHeaderRefreshButton } from '@/widgets/widgets/components/widget-header-refresh-button' import { getHeatmapMarketProviderOptions, @@ -132,7 +132,7 @@ function HeatmapWatchlistSizeControls({ panelId, widgetKey, params }: HeaderCont ) } -function HeatmapPortfolioControls({ panelId, widgetKey, params }: HeaderControlProps) { +function HeatmapPortfolioControls({ workspaceId, panelId, widgetKey, params }: HeaderControlProps) { const providerAvailabilityQuery = useOAuthProviderAvailability( getHeatmapTradingProviderAvailabilityIds() ) diff --git a/apps/tradinggoose/widgets/widgets/heatmap/components/shared.ts b/apps/tradinggoose/widgets/widgets/heatmap/components/shared.ts index 6481ea3fc..d0e7f47e6 100644 --- a/apps/tradinggoose/widgets/widgets/heatmap/components/shared.ts +++ b/apps/tradinggoose/widgets/widgets/heatmap/components/shared.ts @@ -3,8 +3,8 @@ import { getTradingWidgetProviderOptions, } from '@/widgets/utils/trading-widget-providers' import { - resolveConfiguredSeriesMarketProviderId, getSeriesMarketProviderOptions, + resolveConfiguredSeriesMarketProviderId, } from '@/widgets/widgets/data_chart/options' import type { HeatmapSourceMode, @@ -25,7 +25,7 @@ export const HEATMAP_WATCHLIST_SIZE_METRICS: Array<{ { id: 'volume', label: 'Volume' }, ] -const DEFAULT_HEATMAP_TRADING_PROVIDER_OPTIONS = getTradingWidgetProviderOptions('holdings') +const DEFAULT_HEATMAP_TRADING_PROVIDER_OPTIONS = getTradingWidgetProviderOptions('portfolioDetail') export const getHeatmapMarketProviderOptions = () => getSeriesMarketProviderOptions() @@ -43,10 +43,10 @@ export const resolveHeatmapWatchlistSizeMetric = ( ): HeatmapWatchlistSizeMetric => (params?.watchlistSizeMetric === 'volume' ? 'volume' : 'volumeUsd') export const getHeatmapTradingProviderAvailabilityIds = () => - getTradingWidgetProviderAvailabilityIds('holdings') + getTradingWidgetProviderAvailabilityIds('portfolioDetail') export const getHeatmapTradingProviderOptions = (providerAvailability?: Record) => - getTradingWidgetProviderOptions('holdings', providerAvailability) + getTradingWidgetProviderOptions('portfolioDetail', providerAvailability) export const resolveHeatmapTradingProviderId = ( params: HeatmapWidgetParams | null | undefined, diff --git a/apps/tradinggoose/widgets/widgets/list_custom_tool/index.test.tsx b/apps/tradinggoose/widgets/widgets/list_custom_tool/index.test.tsx index eb1eba163..a30584350 100644 --- a/apps/tradinggoose/widgets/widgets/list_custom_tool/index.test.tsx +++ b/apps/tradinggoose/widgets/widgets/list_custom_tool/index.test.tsx @@ -57,7 +57,7 @@ vi.mock('@/components/ui/dropdown-menu', () => ({ ), })) -vi.mock('@/widgets/widgets/components/widget-header-control', () => ({ +vi.mock('@/components/widget-header-control', () => ({ widgetHeaderButtonGroupClassName: () => 'controls', widgetHeaderIconButtonClassName: () => 'icon-button', widgetHeaderMenuContentClassName: 'menu-content', diff --git a/apps/tradinggoose/widgets/widgets/list_custom_tool/index.tsx b/apps/tradinggoose/widgets/widgets/list_custom_tool/index.tsx index b36520b3b..b301d47be 100644 --- a/apps/tradinggoose/widgets/widgets/list_custom_tool/index.tsx +++ b/apps/tradinggoose/widgets/widgets/list_custom_tool/index.tsx @@ -10,6 +10,14 @@ import { } from '@/components/ui/dropdown-menu' import { LoadingAgent } from '@/components/ui/loading-agent' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { + widgetHeaderButtonGroupClassName, + widgetHeaderIconButtonClassName, + widgetHeaderMenuContentClassName, + widgetHeaderMenuIconClassName, + widgetHeaderMenuItemClassName, + widgetHeaderMenuTextClassName, +} from '@/components/widget-header-control' import { parseImportedCustomToolsFile } from '@/lib/custom-tools/import-export' import { cn } from '@/lib/utils' import { @@ -38,14 +46,6 @@ import { CUSTOM_TOOL_LIST_WIDGET_KEY, resolveCustomToolId, } from '@/widgets/widgets/_shared/custom_tool/utils' -import { - widgetHeaderButtonGroupClassName, - widgetHeaderIconButtonClassName, - widgetHeaderMenuContentClassName, - widgetHeaderMenuIconClassName, - widgetHeaderMenuItemClassName, - widgetHeaderMenuTextClassName, -} from '@/widgets/widgets/components/widget-header-control' import { WidgetStateMessage } from '@/widgets/widgets/editor_indicator/components/widget-state-message' const DEFAULT_CUSTOM_TOOL_NAME = 'newCustomTool' diff --git a/apps/tradinggoose/widgets/widgets/list_indicator/components/indicator-create-menu.tsx b/apps/tradinggoose/widgets/widgets/list_indicator/components/indicator-create-menu.tsx index 015dff933..4b5e512e6 100644 --- a/apps/tradinggoose/widgets/widgets/list_indicator/components/indicator-create-menu.tsx +++ b/apps/tradinggoose/widgets/widgets/list_indicator/components/indicator-create-menu.tsx @@ -9,14 +9,14 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { cn } from '@/lib/utils' import { widgetHeaderIconButtonClassName, widgetHeaderMenuContentClassName, widgetHeaderMenuIconClassName, widgetHeaderMenuItemClassName, widgetHeaderMenuTextClassName, -} from '@/widgets/widgets/components/widget-header-control' +} from '@/components/widget-header-control' +import { cn } from '@/lib/utils' interface IndicatorCreateMenuProps { disabled?: boolean diff --git a/apps/tradinggoose/widgets/widgets/list_indicator/index.test.tsx b/apps/tradinggoose/widgets/widgets/list_indicator/index.test.tsx index 68f09e64e..3ef2263f3 100644 --- a/apps/tradinggoose/widgets/widgets/list_indicator/index.test.tsx +++ b/apps/tradinggoose/widgets/widgets/list_indicator/index.test.tsx @@ -55,7 +55,7 @@ vi.mock('@/components/ui/dropdown-menu', () => ({ ), })) -vi.mock('@/widgets/widgets/components/widget-header-control', () => ({ +vi.mock('@/components/widget-header-control', () => ({ widgetHeaderButtonGroupClassName: () => 'controls', widgetHeaderIconButtonClassName: () => 'icon-button', widgetHeaderMenuContentClassName: 'menu-content', diff --git a/apps/tradinggoose/widgets/widgets/list_indicator/index.tsx b/apps/tradinggoose/widgets/widgets/list_indicator/index.tsx index 560930b14..193cac1e0 100644 --- a/apps/tradinggoose/widgets/widgets/list_indicator/index.tsx +++ b/apps/tradinggoose/widgets/widgets/list_indicator/index.tsx @@ -2,6 +2,7 @@ import { useCallback } from 'react' import { ListChecks } from 'lucide-react' +import { widgetHeaderButtonGroupClassName } from '@/components/widget-header-control' import { parseImportedIndicatorsFile } from '@/lib/indicators/import-export' import { useUserPermissionsContext, @@ -14,7 +15,6 @@ import type { IndicatorDefinition } from '@/stores/indicators/types' import type { PairColor } from '@/widgets/pair-colors' import type { DashboardWidgetDefinition, WidgetComponentProps } from '@/widgets/types' import { emitIndicatorSelectionChange } from '@/widgets/utils/indicator-selection' -import { widgetHeaderButtonGroupClassName } from '@/widgets/widgets/components/widget-header-control' import { IndicatorCreateMenu } from '@/widgets/widgets/list_indicator/components/indicator-create-menu' import { IndicatorList, diff --git a/apps/tradinggoose/widgets/widgets/list_mcp/index.tsx b/apps/tradinggoose/widgets/widgets/list_mcp/index.tsx index 5f7b0e834..28755b5bc 100644 --- a/apps/tradinggoose/widgets/widgets/list_mcp/index.tsx +++ b/apps/tradinggoose/widgets/widgets/list_mcp/index.tsx @@ -21,6 +21,14 @@ import { } from '@/components/ui/dropdown-menu' import { LoadingAgent } from '@/components/ui/loading-agent' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { + widgetHeaderButtonGroupClassName, + widgetHeaderIconButtonClassName, + widgetHeaderMenuContentClassName, + widgetHeaderMenuIconClassName, + widgetHeaderMenuItemClassName, + widgetHeaderMenuTextClassName, +} from '@/components/widget-header-control' import { cn } from '@/lib/utils' import { useUserPermissionsContext, @@ -35,14 +43,6 @@ import type { DashboardWidgetDefinition, WidgetComponentProps } from '@/widgets/ import { MCP_SERVER_DEFAULTS } from '@/widgets/utils/mcp-defaults' import { emitMcpSelectionChange, useMcpSelectionPersistence } from '@/widgets/utils/mcp-selection' import { resolveMcpServerId } from '@/widgets/widgets/_shared/mcp/utils' -import { - widgetHeaderButtonGroupClassName, - widgetHeaderIconButtonClassName, - widgetHeaderMenuContentClassName, - widgetHeaderMenuIconClassName, - widgetHeaderMenuItemClassName, - widgetHeaderMenuTextClassName, -} from '@/widgets/widgets/components/widget-header-control' const DEFAULT_MCP_SERVER = { ...MCP_SERVER_DEFAULTS, diff --git a/apps/tradinggoose/widgets/widgets/list_skill/components/skill-create-menu.tsx b/apps/tradinggoose/widgets/widgets/list_skill/components/skill-create-menu.tsx index 421a61fd3..20e75a5fa 100644 --- a/apps/tradinggoose/widgets/widgets/list_skill/components/skill-create-menu.tsx +++ b/apps/tradinggoose/widgets/widgets/list_skill/components/skill-create-menu.tsx @@ -9,14 +9,14 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { cn } from '@/lib/utils' import { widgetHeaderIconButtonClassName, widgetHeaderMenuContentClassName, widgetHeaderMenuIconClassName, widgetHeaderMenuItemClassName, widgetHeaderMenuTextClassName, -} from '@/widgets/widgets/components/widget-header-control' +} from '@/components/widget-header-control' +import { cn } from '@/lib/utils' interface SkillCreateMenuProps { disabled?: boolean diff --git a/apps/tradinggoose/widgets/widgets/list_skill/index.test.tsx b/apps/tradinggoose/widgets/widgets/list_skill/index.test.tsx index fc465b9f8..183c6922c 100644 --- a/apps/tradinggoose/widgets/widgets/list_skill/index.test.tsx +++ b/apps/tradinggoose/widgets/widgets/list_skill/index.test.tsx @@ -56,7 +56,7 @@ vi.mock('@/components/ui/dropdown-menu', () => ({ ), })) -vi.mock('@/widgets/widgets/components/widget-header-control', () => ({ +vi.mock('@/components/widget-header-control', () => ({ widgetHeaderButtonGroupClassName: () => 'controls', widgetHeaderIconButtonClassName: () => 'icon-button', widgetHeaderMenuContentClassName: 'menu-content', diff --git a/apps/tradinggoose/widgets/widgets/list_skill/index.tsx b/apps/tradinggoose/widgets/widgets/list_skill/index.tsx index d351bb392..dec9540ee 100644 --- a/apps/tradinggoose/widgets/widgets/list_skill/index.tsx +++ b/apps/tradinggoose/widgets/widgets/list_skill/index.tsx @@ -2,6 +2,7 @@ import { useCallback } from 'react' import { ToolCase } from 'lucide-react' +import { widgetHeaderButtonGroupClassName } from '@/components/widget-header-control' import { parseImportedSkillsFile } from '@/lib/skills/import-export' import { useUserPermissionsContext, @@ -18,7 +19,6 @@ import { SKILL_EDITOR_WIDGET_KEY, SKILL_LIST_WIDGET_KEY, } from '@/widgets/widgets/_shared/skill/utils' -import { widgetHeaderButtonGroupClassName } from '@/widgets/widgets/components/widget-header-control' import { SkillCreateMenu } from '@/widgets/widgets/list_skill/components/skill-create-menu' import { SkillList, diff --git a/apps/tradinggoose/widgets/widgets/list_workflow/components/workflow-create-menu.tsx b/apps/tradinggoose/widgets/widgets/list_workflow/components/workflow-create-menu.tsx index 8c9a5c991..c3f361974 100644 --- a/apps/tradinggoose/widgets/widgets/list_workflow/components/workflow-create-menu.tsx +++ b/apps/tradinggoose/widgets/widgets/list_workflow/components/workflow-create-menu.tsx @@ -9,6 +9,13 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { + widgetHeaderIconButtonClassName, + widgetHeaderMenuContentClassName, + widgetHeaderMenuIconClassName, + widgetHeaderMenuItemClassName, + widgetHeaderMenuTextClassName, +} from '@/components/widget-header-control' import { createLogger } from '@/lib/logs/console/logger' import { generateFolderName } from '@/lib/naming' import { cn } from '@/lib/utils' @@ -18,13 +25,6 @@ import { useImportSkills } from '@/hooks/queries/skills' import { useFolderStore } from '@/stores/folders/store' import { parseWorkflowJson } from '@/stores/workflows/json/importer' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { - widgetHeaderIconButtonClassName, - widgetHeaderMenuContentClassName, - widgetHeaderMenuIconClassName, - widgetHeaderMenuItemClassName, - widgetHeaderMenuTextClassName, -} from '@/widgets/widgets/components/widget-header-control' import { buildImportedWorkflowSkillsLookup } from './workflow-create-menu.utils' const logger = createLogger('DashboardWorkflowCreateMenu') diff --git a/apps/tradinggoose/widgets/widgets/list_workflow/index.tsx b/apps/tradinggoose/widgets/widgets/list_workflow/index.tsx index 2c5945f6f..d140af55b 100644 --- a/apps/tradinggoose/widgets/widgets/list_workflow/index.tsx +++ b/apps/tradinggoose/widgets/widgets/list_workflow/index.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { LayoutList } from 'lucide-react' import { shallow } from 'zustand/shallow' import { LoadingAgent } from '@/components/ui/loading-agent' +import { widgetHeaderButtonGroupClassName } from '@/components/widget-header-control' import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useSetPairColorContext } from '@/stores/dashboard/pair-store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -11,7 +12,6 @@ import type { WorkflowMetadata } from '@/stores/workflows/registry/types' import { WORKSPACE_BOOTSTRAP_CHANNEL } from '@/stores/workflows/registry/types' import type { PairColor } from '@/widgets/pair-colors' import type { DashboardWidgetDefinition, WidgetComponentProps } from '@/widgets/types' -import { widgetHeaderButtonGroupClassName } from '@/widgets/widgets/components/widget-header-control' import { WorkflowRouteProvider } from '@/widgets/widgets/editor_workflow/context/workflow-route-context' import { DashboardWorkflowCreateMenu } from '@/widgets/widgets/list_workflow/components/workflow-create-menu' import { FolderTree } from './components/folder-tree/folder-tree' diff --git a/apps/tradinggoose/widgets/widgets/portfolio_snapshot/components/body.test.tsx b/apps/tradinggoose/widgets/widgets/portfolio_snapshot/components/body.test.tsx index 99ee6b43f..706305a0a 100644 --- a/apps/tradinggoose/widgets/widgets/portfolio_snapshot/components/body.test.tsx +++ b/apps/tradinggoose/widgets/widgets/portfolio_snapshot/components/body.test.tsx @@ -17,7 +17,7 @@ const mockEmitPortfolioSnapshotParamsChange = vi.fn() const selectedPortfolioIdentity = { providerId: 'alpaca', - tokenAccountId: 'oauth-account-1', + credentialId: 'oauth-account-1', serviceId: 'alpaca-live', accountId: 'acct-1', accountName: 'Paper', @@ -38,14 +38,7 @@ const createPortfolioPosition = ( quantity: number, listing = createListing(symbol) ) => ({ - symbol: { - base: symbol, - quote: 'USD', - assetClass: 'stock' as const, - active: true, - rank: 0, - listing, - }, + listingIdentity: listing, quantity, }) @@ -232,7 +225,7 @@ describe('PortfolioSnapshotWidgetBody', () => { it('clears the saved account when the saved service has disconnected', async () => { const connectedPaperIdentity = { ...selectedPortfolioIdentity, - tokenAccountId: 'oauth-account-paper', + credentialId: 'oauth-account-paper', serviceId: 'alpaca-paper', accountId: 'paper-acct', accountName: 'Paper Account', @@ -347,7 +340,7 @@ describe('PortfolioSnapshotWidgetBody', () => { const tradierPortfolioIdentity = { ...selectedPortfolioIdentity, providerId: 'tradier', - tokenAccountId: 'oauth-account-2', + credentialId: 'oauth-account-2', serviceId: 'tradier-live', } mockUsePortfolioIdentities.mockReturnValue( @@ -468,6 +461,7 @@ describe('PortfolioSnapshotWidgetBody', () => { expect(container.textContent).toContain('Alpaca · active · paper') expect(container.textContent).toContain('performance-chart') expect(mockUsePortfolioDetail).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', provider: 'alpaca', serviceId: 'alpaca-live', portfolioIdentity: selectedPortfolioIdentity, diff --git a/apps/tradinggoose/widgets/widgets/portfolio_snapshot/components/body.tsx b/apps/tradinggoose/widgets/widgets/portfolio_snapshot/components/body.tsx index b54fed909..2fff8a87d 100644 --- a/apps/tradinggoose/widgets/widgets/portfolio_snapshot/components/body.tsx +++ b/apps/tradinggoose/widgets/widgets/portfolio_snapshot/components/body.tsx @@ -11,12 +11,9 @@ import { MARKET_QUOTE_SNAPSHOT_REQUEST_CAP } from '@/lib/market/quote-snapshot-c import { cn } from '@/lib/utils' import { useMarketQuoteSnapshots } from '@/hooks/queries/market-quote-snapshots' import { useOAuthProviderAvailability } from '@/hooks/queries/oauth-provider-availability' -import { - usePortfolioDetail, - usePortfolioPerformance, -} from '@/hooks/queries/trading-portfolio' -import { getTradingProviderDefinition } from '@/providers/trading/providers' +import { usePortfolioDetail, usePortfolioPerformance } from '@/hooks/queries/trading-portfolio' import { getPortfolioListingExposures } from '@/providers/trading/portfolio-selectors' +import { getTradingProviderDefinition } from '@/providers/trading/providers' import type { TradingPortfolioPerformanceWindow } from '@/providers/trading/types' import type { WidgetComponentProps } from '@/widgets/types' import { @@ -264,23 +261,19 @@ export function PortfolioSnapshotWidgetBody({ widgetParams?.selectedWindow, ]) - const { - accountsQuery, - activeServiceId, - activePortfolioIdentity, - services, - portfolioIdentities, - } = usePortfolioIdentitySelection({ - providerId, - serviceId: widgetParams?.serviceId, - portfolioIdentity: widgetParams?.portfolioIdentity, - enabled: isProviderReady, - panelId, - widgetKey, - emitParamsChange: emitPortfolioSnapshotParamsChange, - }) + const { accountsQuery, activeServiceId, activePortfolioIdentity, services, portfolioIdentities } = + usePortfolioIdentitySelection({ + providerId, + serviceId: widgetParams?.serviceId, + portfolioIdentity: widgetParams?.portfolioIdentity, + enabled: isProviderReady, + panelId, + widgetKey, + emitParamsChange: emitPortfolioSnapshotParamsChange, + }) const snapshotQuery = usePortfolioDetail({ + workspaceId: workspaceId ?? undefined, provider: isProviderReady ? providerId : undefined, serviceId: activeServiceId, portfolioIdentity: activePortfolioIdentity, @@ -322,6 +315,7 @@ export function PortfolioSnapshotWidgetBody({ }) const performanceQuery = usePortfolioPerformance({ + workspaceId: workspaceId ?? undefined, provider: isProviderReady ? providerId : undefined, serviceId: activeServiceId, portfolioIdentity: activePortfolioIdentity, @@ -460,7 +454,7 @@ export function PortfolioSnapshotWidgetBody({ : (quotedPositionsHint ?? (marketProviderId ? quoteItems.length > 0 - ? `${quoteSummary.quotedPositions}/${cappedQuotePositions.length} quoted` + ? `${quoteSummary.quotedPositions}/${cappedQuotePositions.length} quoted` : 'No holdings with market listings' : 'No market provider'))) const accountMetaText = [ diff --git a/apps/tradinggoose/widgets/widgets/portfolio_snapshot/components/header.test.tsx b/apps/tradinggoose/widgets/widgets/portfolio_snapshot/components/header.test.tsx index 43cb1e874..815eec851 100644 --- a/apps/tradinggoose/widgets/widgets/portfolio_snapshot/components/header.test.tsx +++ b/apps/tradinggoose/widgets/widgets/portfolio_snapshot/components/header.test.tsx @@ -22,7 +22,7 @@ const mockTradingAccountSelector = vi.fn(({ onAccountSelect }: MockTradingAccoun onAccountSelect?.({ portfolioIdentity: { providerId: 'alpaca', - tokenAccountId: 'oauth-account-1', + credentialId: 'oauth-account-1', serviceId: 'alpaca-live', accountId: 'acct-1', }, @@ -42,17 +42,17 @@ vi.mock('@/widgets/utils/portfolio-snapshot-params', () => ({ mockEmitPortfolioSnapshotParamsChange(...args), })) -vi.mock('@/widgets/widgets/components/widget-header-control', () => ({ +vi.mock('@/components/widget-header-control', () => ({ widgetHeaderButtonGroupClassName: (className?: string) => ['controls', className].filter(Boolean).join(' '), widgetHeaderIconButtonClassName: () => 'icon-button', })) -vi.mock('@/widgets/widgets/components/market-provider-settings-button', () => ({ +vi.mock('@/components/market-selector/provider-settings-button', () => ({ MarketProviderSettingsButton: () => , })) -vi.mock('@/widgets/widgets/components/market-provider-selector', () => ({ +vi.mock('@/components/market-selector/provider-selector', () => ({ MarketProviderSelector: ({ value, onChange, @@ -70,7 +70,7 @@ vi.mock('@/widgets/widgets/components/market-provider-selector', () => ({ ), })) -vi.mock('@/widgets/widgets/components/trading-provider-selector', () => ({ +vi.mock('@/components/trading-selector/provider-selector', () => ({ TradingProviderSelector: ({ value, onChange, @@ -88,7 +88,7 @@ vi.mock('@/widgets/widgets/components/trading-provider-selector', () => ({ ), })) -vi.mock('@/widgets/widgets/components/trading-account-selector', () => ({ +vi.mock('@/components/trading-selector/account-selector', () => ({ TradingAccountSelector: (props: MockTradingAccountSelectorProps) => mockTradingAccountSelector(props), })) @@ -146,7 +146,7 @@ describe('PortfolioSnapshotHeaderControls', () => { provider: 'alpaca', portfolioIdentity: { providerId: 'alpaca', - tokenAccountId: 'oauth-account-1', + credentialId: 'oauth-account-1', serviceId: 'alpaca-live', accountId: 'acct-1', }, @@ -220,7 +220,7 @@ describe('PortfolioSnapshotHeaderControls', () => { params: { portfolioIdentity: { providerId: 'alpaca', - tokenAccountId: 'oauth-account-1', + credentialId: 'oauth-account-1', serviceId: 'alpaca-live', accountId: 'acct-1', }, diff --git a/apps/tradinggoose/widgets/widgets/portfolio_snapshot/components/header.tsx b/apps/tradinggoose/widgets/widgets/portfolio_snapshot/components/header.tsx index 9d38c52f8..10a231e2e 100644 --- a/apps/tradinggoose/widgets/widgets/portfolio_snapshot/components/header.tsx +++ b/apps/tradinggoose/widgets/widgets/portfolio_snapshot/components/header.tsx @@ -1,12 +1,12 @@ 'use client' import { useMemo } from 'react' +import { MarketProviderControls } from '@/components/market-selector/provider-controls' +import { TradingProviderControls } from '@/components/trading-selector/provider-controls' +import { widgetHeaderButtonGroupClassName } from '@/components/widget-header-control' import { useOAuthProviderAvailability } from '@/hooks/queries/oauth-provider-availability' import type { DashboardWidgetDefinition } from '@/widgets/types' import { emitPortfolioSnapshotParamsChange } from '@/widgets/utils/portfolio-snapshot-params' -import { MarketProviderControls } from '@/widgets/widgets/components/market-provider-controls' -import { TradingProviderControls } from '@/widgets/widgets/components/trading-provider-controls' -import { widgetHeaderButtonGroupClassName } from '@/widgets/widgets/components/widget-header-control' import { WidgetHeaderRefreshButton } from '@/widgets/widgets/components/widget-header-refresh-button' import { getPortfolioSnapshotMarketProviderOptions, diff --git a/apps/tradinggoose/widgets/widgets/portfolio_snapshot/components/shared.ts b/apps/tradinggoose/widgets/widgets/portfolio_snapshot/components/shared.ts index ed8babd7b..89e54c1f7 100644 --- a/apps/tradinggoose/widgets/widgets/portfolio_snapshot/components/shared.ts +++ b/apps/tradinggoose/widgets/widgets/portfolio_snapshot/components/shared.ts @@ -1,24 +1,25 @@ import { getTradingPortfolioSupportedWindows } from '@/providers/trading/portfolio' import type { TradingPortfolioPerformanceWindow } from '@/providers/trading/types' -import { - resolveConfiguredSeriesMarketProviderId, - getSeriesMarketProviderOptions, -} from '@/widgets/widgets/data_chart/options' import { getTradingWidgetProviderAvailabilityIds, getTradingWidgetProviderOptions, resolveTradingWidgetProviderId, } from '@/widgets/utils/trading-widget-providers' +import { + getSeriesMarketProviderOptions, + resolveConfiguredSeriesMarketProviderId, +} from '@/widgets/widgets/data_chart/options' import type { PortfolioSnapshotWidgetParams } from '@/widgets/widgets/portfolio_snapshot/types' -const DEFAULT_PORTFOLIO_SNAPSHOT_PROVIDER_OPTIONS = getTradingWidgetProviderOptions('holdings') +const DEFAULT_PORTFOLIO_SNAPSHOT_PROVIDER_OPTIONS = + getTradingWidgetProviderOptions('portfolioDetail') export const getPortfolioSnapshotProviderAvailabilityIds = () => - getTradingWidgetProviderAvailabilityIds('holdings') + getTradingWidgetProviderAvailabilityIds('portfolioDetail') export const getPortfolioSnapshotProviderOptions = ( providerAvailability?: Record -) => getTradingWidgetProviderOptions('holdings', providerAvailability) +) => getTradingWidgetProviderOptions('portfolioDetail', providerAvailability) export const resolvePortfolioSnapshotProviderId = ( params: PortfolioSnapshotWidgetParams | null | undefined, diff --git a/apps/tradinggoose/widgets/widgets/quick_order/components/body.test.tsx b/apps/tradinggoose/widgets/widgets/quick_order/components/body.test.tsx index a20f9805c..e59c5941d 100644 --- a/apps/tradinggoose/widgets/widgets/quick_order/components/body.test.tsx +++ b/apps/tradinggoose/widgets/widgets/quick_order/components/body.test.tsx @@ -20,7 +20,7 @@ const mockReset = vi.fn() const portfolioIdentity = { providerId: 'alpaca', - tokenAccountId: 'oauth-account-1', + credentialId: 'oauth-account-1', serviceId: 'alpaca-live', accountId: 'acct-1', accountName: 'Paper Account', @@ -524,6 +524,7 @@ describe('QuickOrderWidgetBody', () => { side: 'buy', }) expect(mockUsePortfolioDetail).toHaveBeenLastCalledWith({ + workspaceId: 'workspace-1', provider: 'alpaca', serviceId: 'alpaca-live', portfolioIdentity: undefined, diff --git a/apps/tradinggoose/widgets/widgets/quick_order/components/body.tsx b/apps/tradinggoose/widgets/widgets/quick_order/components/body.tsx index ee592f07d..7bbf506eb 100644 --- a/apps/tradinggoose/widgets/widgets/quick_order/components/body.tsx +++ b/apps/tradinggoose/widgets/widgets/quick_order/components/body.tsx @@ -263,6 +263,7 @@ export function QuickOrderWidgetBody({ emitParamsChange: emitQuickOrderParamsChange, }) const accountSnapshotQuery = usePortfolioDetail({ + workspaceId: workspaceId ?? undefined, provider: hasSelectedProvider && areProviderOptionsReady ? providerId : undefined, serviceId: activeServiceId, portfolioIdentity: activePortfolioIdentity, diff --git a/apps/tradinggoose/widgets/widgets/quick_order/components/header.test.tsx b/apps/tradinggoose/widgets/widgets/quick_order/components/header.test.tsx index 21c5a3e77..ff7e11cd9 100644 --- a/apps/tradinggoose/widgets/widgets/quick_order/components/header.test.tsx +++ b/apps/tradinggoose/widgets/widgets/quick_order/components/header.test.tsx @@ -69,7 +69,7 @@ const mockTradingAccountSelector = vi.fn(({ onAccountSelect }: MockTradingAccoun onAccountSelect?.({ portfolioIdentity: { providerId: 'alpaca', - tokenAccountId: 'oauth-account-1', + credentialId: 'oauth-account-1', serviceId: 'alpaca-live', accountId: 'acct-1', }, @@ -88,12 +88,12 @@ vi.mock('@/widgets/utils/quick-order-params', () => ({ emitQuickOrderParamsChange: (...args: unknown[]) => mockEmitQuickOrderParamsChange(...args), })) -vi.mock('@/widgets/widgets/components/market-provider-controls', () => ({ +vi.mock('@/components/market-selector/provider-controls', () => ({ MarketProviderControls: (props: MockMarketProviderControlsProps) => mockMarketProviderControls(props), })) -vi.mock('@/widgets/widgets/components/trading-provider-selector', () => ({ +vi.mock('@/components/trading-selector/provider-selector', () => ({ TradingProviderSelector: ({ onChange }: { onChange: (provider: string) => void }) => ( - - - Provider settings - - -
-

Provider settings

-

Save credentials for this widget.

-
-
- {definitions.map((definition) => { - const inputId = `provider-param-${providerId ?? 'unknown'}-${definition.id}` - const isPassword = definition.password || definition.id.toLowerCase().includes('secret') - const resolvedValue = - resolveSavedValue({ - definition, - authParams, - providerParams, - }) ?? definition.defaultValue - const selectValue = - typeof resolvedValue === 'string' || typeof resolvedValue === 'number' - ? String(resolvedValue) - : undefined - const inputValue = - typeof resolvedValue === 'string' || typeof resolvedValue === 'number' - ? String(resolvedValue) - : typeof resolvedValue === 'object' && resolvedValue !== null - ? JSON.stringify(resolvedValue) - : undefined - const booleanValue = - typeof resolvedValue === 'boolean' - ? resolvedValue - : typeof resolvedValue === 'string' - ? resolvedValue.toLowerCase() === 'true' - : false - const controlledValue = inputValues[definition.id] ?? (inputValue ?? '') - const shortInputConfig: SubBlockConfig = { - id: definition.id, - title: definition.title ?? definition.id, - type: 'short-input', - inputType: definition.type === 'number' ? 'number' : 'text', - placeholder: definition.placeholder, - min: definition.min, - max: definition.max, - step: definition.step, - integer: definition.integer, - connectionDroppable: false, - } - - if (definition.inputType === 'switch' || definition.type === 'boolean') { - return ( -
- - handleParamChange(definition.id, checked)} - /> -
- ) - } - - if (definition.options?.length) { - return ( -
- - -
- ) - } - - return ( -
- - { - setInputValues((current) => ({ ...current, [definition.id]: value })) - handleParamChange(definition.id, value) - }} - placeholder={definition.placeholder} - password={isPassword} - workspaceId={workspaceId} - enableTags={false} - /> -
- ) - })} -
-
- - -
-
- - ) -} diff --git a/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-header-controls.tsx b/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-header-controls.tsx index 74ba3853f..6a8d6cd46 100644 --- a/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-header-controls.tsx +++ b/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-header-controls.tsx @@ -2,6 +2,7 @@ import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Check } from 'lucide-react' +import { MarketProviderControls } from '@/components/market-selector/provider-controls' import { AlertDialog, AlertDialogAction, @@ -13,6 +14,10 @@ import { AlertDialogTitle, } from '@/components/ui/alert-dialog' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { + widgetHeaderButtonGroupClassName, + widgetHeaderIconButtonClassName, +} from '@/components/widget-header-control' import { type ListingOption, toListingValue } from '@/lib/listing/identity' import { parseImportedWatchlistFile } from '@/lib/watchlists/import-export' import type { WatchlistRecord } from '@/lib/watchlists/types' @@ -30,11 +35,6 @@ import { useListingSelectorStore } from '@/stores/market/selector/store' import type { WidgetInstance } from '@/widgets/layout' import type { DashboardWidgetDefinition } from '@/widgets/types' import { emitWatchlistParamsChange } from '@/widgets/utils/watchlist-params' -import { MarketProviderControls } from '@/widgets/widgets/components/market-provider-controls' -import { - widgetHeaderButtonGroupClassName, - widgetHeaderIconButtonClassName, -} from '@/widgets/widgets/components/widget-header-control' import { WidgetHeaderRefreshButton } from '@/widgets/widgets/components/widget-header-refresh-button' import { DataChartListingSelector } from '@/widgets/widgets/data_chart/components/listing-control' import { diff --git a/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-header-controls.ui.test.tsx b/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-header-controls.ui.test.tsx index 3b3498c0c..e0464da2e 100644 --- a/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-header-controls.ui.test.tsx +++ b/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-header-controls.ui.test.tsx @@ -105,7 +105,7 @@ vi.mock('@/components/ui/tooltip', () => ({ TooltipContent: ({ children }: { children: ReactNode }) =>
{children}
, })) -vi.mock('@/widgets/widgets/components/widget-header-control', () => ({ +vi.mock('@/components/widget-header-control', () => ({ widgetHeaderButtonGroupClassName: (className?: string) => ['controls', className].filter(Boolean).join(' '), widgetHeaderIconButtonClassName: () => 'icon-button', diff --git a/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-header-left-controls.ui.test.tsx b/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-header-left-controls.ui.test.tsx index 1df444725..0491b9e2d 100644 --- a/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-header-left-controls.ui.test.tsx +++ b/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-header-left-controls.ui.test.tsx @@ -13,7 +13,7 @@ vi.mock('@/widgets/utils/watchlist-params', () => ({ emitWatchlistParamsChange: (...args: unknown[]) => mockEmitWatchlistParamsChange(...args), })) -vi.mock('@/widgets/widgets/components/market-provider-controls', () => ({ +vi.mock('@/components/market-selector/provider-controls', () => ({ MarketProviderControls: (props: { value?: string className?: string @@ -43,7 +43,7 @@ vi.mock('@/widgets/widgets/components/market-provider-controls', () => ({ }, })) -vi.mock('@/widgets/widgets/components/widget-header-control', () => ({ +vi.mock('@/components/widget-header-control', () => ({ widgetHeaderButtonGroupClassName: (className?: string) => ['controls', className].filter(Boolean).join(' '), })) diff --git a/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-list-actions-button.test.tsx b/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-list-actions-button.test.tsx index 349c602ae..37c79b41a 100644 --- a/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-list-actions-button.test.tsx +++ b/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-list-actions-button.test.tsx @@ -22,7 +22,7 @@ vi.mock('@/components/ui/tooltip', () => ({ TooltipContent: ({ children }: { children: ReactNode }) =>
{children}
, })) -vi.mock('@/widgets/widgets/components/widget-header-control', () => ({ +vi.mock('@/components/widget-header-control', () => ({ widgetHeaderIconButtonClassName: () => 'icon-button', widgetHeaderMenuItemClassName: 'menu-item', })) @@ -66,15 +66,17 @@ const getMenuButtons = (tree: ReactNode) => { const findMenuButton = (items: ReactElement[], label: string) => items.find((item) => Children.toArray((item.props as { children?: ReactNode }).children).some( - (child) => - isValidElement<{ children?: ReactNode }>(child) && child.props.children === label + (child) => isValidElement<{ children?: ReactNode }>(child) && child.props.children === label ) ) as ReactElement<{ onClick?: () => void }> | undefined describe('WatchlistListActionsButton', () => { it('prevents popover auto-focus when opening list actions', () => { const tree = WatchlistListActionsButton(createProps()) - const content = findElementByType(tree, (element) => element.type === popoverMocks.PopoverContent) + const content = findElementByType( + tree, + (element) => element.type === popoverMocks.PopoverContent + ) expect(content).not.toBeNull() @@ -169,7 +171,10 @@ describe('WatchlistListActionsButton', () => { }) const trigger = findElementByType(tree, (element) => element.type === 'button') - const content = findElementByType(tree, (element) => element.type === popoverMocks.PopoverContent) + const content = findElementByType( + tree, + (element) => element.type === popoverMocks.PopoverContent + ) expect((trigger?.props as { disabled?: boolean } | undefined)?.disabled).toBe(true) expect(content).toBeNull() diff --git a/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-list-actions-button.tsx b/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-list-actions-button.tsx index 2dbbdf43e..cfb4fb8ee 100644 --- a/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-list-actions-button.tsx +++ b/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-list-actions-button.tsx @@ -7,7 +7,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { widgetHeaderIconButtonClassName, widgetHeaderMenuItemClassName, -} from '@/widgets/widgets/components/widget-header-control' +} from '@/components/widget-header-control' type WatchlistListActionsButtonProps = { open: boolean diff --git a/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-list-selector.test.tsx b/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-list-selector.test.tsx index b15c21638..3e9e82789 100644 --- a/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-list-selector.test.tsx +++ b/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-list-selector.test.tsx @@ -2,13 +2,8 @@ * @vitest-environment jsdom */ +import type { ButtonHTMLAttributes, HTMLAttributes, InputHTMLAttributes, ReactNode } from 'react' import { act } from 'react' -import type { - ButtonHTMLAttributes, - HTMLAttributes, - InputHTMLAttributes, - ReactNode, -} from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { WatchlistListSelector } from '@/widgets/widgets/watchlist/components/watchlist-list-selector' @@ -27,11 +22,7 @@ vi.mock('@/components/ui/dropdown-menu', () => ({ {children} ), - DropdownMenuItem: ({ - children, - className, - ...props - }: HTMLAttributes) => ( + DropdownMenuItem: ({ children, className, ...props }: HTMLAttributes) => (
{children}
@@ -68,7 +59,7 @@ vi.mock('@/components/ui/alert-dialog', () => ({ AlertDialogTitle: ({ children }: { children: ReactNode }) =>
{children}
, })) -vi.mock('@/widgets/widgets/components/widget-header-control', () => ({ +vi.mock('@/components/widget-header-control', () => ({ widgetHeaderControlClassName: (className?: string) => ['trigger', className].filter(Boolean).join(' '), widgetHeaderMenuContentClassName: 'content', @@ -143,7 +134,9 @@ describe('WatchlistListSelector', () => { expect(renameButton).toBeTruthy() await act(async () => { - renameButton?.dispatchEvent(new globalThis.MouseEvent('click', { bubbles: true, cancelable: true })) + renameButton?.dispatchEvent( + new globalThis.MouseEvent('click', { bubbles: true, cancelable: true }) + ) }) const input = container.querySelector('input[value="Favorites"]') as HTMLInputElement | null @@ -152,10 +145,7 @@ describe('WatchlistListSelector', () => { await act(async () => { if (!input) return - const valueSetter = Object.getOwnPropertyDescriptor( - HTMLInputElement.prototype, - 'value' - )?.set + const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set valueSetter?.call(input, 'Tech') input.dispatchEvent(new Event('input', { bubbles: true })) input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) @@ -203,7 +193,9 @@ describe('WatchlistListSelector', () => { expect(deleteButton).toBeTruthy() await act(async () => { - deleteButton?.dispatchEvent(new globalThis.MouseEvent('click', { bubbles: true, cancelable: true })) + deleteButton?.dispatchEvent( + new globalThis.MouseEvent('click', { bubbles: true, cancelable: true }) + ) }) expect(container.textContent).toContain('Delete watchlist?') @@ -215,7 +207,9 @@ describe('WatchlistListSelector', () => { expect(confirmButton).toBeTruthy() await act(async () => { - confirmButton?.dispatchEvent(new globalThis.MouseEvent('click', { bubbles: true, cancelable: true })) + confirmButton?.dispatchEvent( + new globalThis.MouseEvent('click', { bubbles: true, cancelable: true }) + ) }) expect(onDeleteWatchlist).toHaveBeenCalledWith('favorites') diff --git a/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-list-selector.tsx b/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-list-selector.tsx index 8c9d7143b..4ae4d5338 100644 --- a/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-list-selector.tsx +++ b/apps/tradinggoose/widgets/widgets/watchlist/components/watchlist-list-selector.tsx @@ -31,14 +31,14 @@ import { import { Input } from '@/components/ui/input' import { ScrollArea } from '@/components/ui/scroll-area' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { cn } from '@/lib/utils' -import type { WatchlistRecord } from '@/lib/watchlists/types' import { widgetHeaderControlClassName, widgetHeaderMenuContentClassName, widgetHeaderMenuItemClassName, widgetHeaderMenuTextClassName, -} from '@/widgets/widgets/components/widget-header-control' +} from '@/components/widget-header-control' +import { cn } from '@/lib/utils' +import type { WatchlistRecord } from '@/lib/watchlists/types' type WatchlistListSelectorProps = { watchlists: WatchlistRecord[] @@ -160,10 +160,7 @@ export const WatchlistListSelector = ({ } const stopItemAction = ( - event: - | MouseEvent - | PointerEvent - | FocusEvent + event: MouseEvent | PointerEvent | FocusEvent ) => { event.stopPropagation() } @@ -206,9 +203,7 @@ export const WatchlistListSelector = ({ {selectionLabel} diff --git a/apps/tradinggoose/widgets/widgets/workflow_chat/index.test.tsx b/apps/tradinggoose/widgets/widgets/workflow_chat/index.test.tsx index d3a52ce36..0da39cc38 100644 --- a/apps/tradinggoose/widgets/widgets/workflow_chat/index.test.tsx +++ b/apps/tradinggoose/widgets/widgets/workflow_chat/index.test.tsx @@ -69,7 +69,7 @@ vi.mock('@/widgets/hooks/use-workflow-widget-state', () => ({ useWorkflowWidgetState: () => mockWorkflowWidgetState, })) -vi.mock('@/widgets/widgets/components/widget-header-control', () => ({ +vi.mock('@/components/widget-header-control', () => ({ widgetHeaderButtonGroupClassName: () => 'controls', widgetHeaderControlClassName: (className?: string) => className ?? '', widgetHeaderIconButtonClassName: () => 'icon-button', diff --git a/apps/tradinggoose/widgets/widgets/workflow_chat/index.tsx b/apps/tradinggoose/widgets/widgets/workflow_chat/index.tsx index 18acb92b7..12aab9963 100644 --- a/apps/tradinggoose/widgets/widgets/workflow_chat/index.tsx +++ b/apps/tradinggoose/widgets/widgets/workflow_chat/index.tsx @@ -4,22 +4,22 @@ import { useCallback, useMemo } from 'react' import { Ban, MessageCircle } from 'lucide-react' import { LoadingAgent } from '@/components/ui/loading-agent' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { + widgetHeaderButtonGroupClassName, + widgetHeaderControlClassName, + widgetHeaderIconButtonClassName, +} from '@/components/widget-header-control' import { useChatStore } from '@/stores/chat/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { resolveWidgetChannel } from '@/widgets/hooks/use-widget-channel' import { useWorkflowWidgetState } from '@/widgets/hooks/use-workflow-widget-state' import type { WidgetInstance } from '@/widgets/layout' import type { DashboardWidgetDefinition, WidgetComponentProps } from '@/widgets/types' -import { - widgetHeaderButtonGroupClassName, - widgetHeaderControlClassName, - widgetHeaderIconButtonClassName, -} from '@/widgets/widgets/components/widget-header-control' -import { WorkflowDropdown } from '@/widgets/widgets/components/workflow-dropdown' import { emitWorkflowSelectionChange, useWorkflowSelectionPersistence, } from '@/widgets/utils/workflow-selection' +import { WorkflowDropdown } from '@/widgets/widgets/components/workflow-dropdown' import { OutputSelect } from './components' import WorkflowChatApp, { WorkflowChatSessionProviders } from './components/workflow-chat-app' @@ -301,9 +301,7 @@ export const chatWidget: DashboardWidgetDefinition = { workspaceId={context?.workspaceId} channelId={channelId} fallbackWorkflowId={workflowIdParam} - triggerClassName={widgetHeaderControlClassName( - 'flex items-center gap-1 min-w-[240px]' - )} + triggerClassName={widgetHeaderControlClassName('flex items-center gap-1 min-w-[240px]')} /> ), diff --git a/apps/tradinggoose/widgets/widgets/workflow_console/index.tsx b/apps/tradinggoose/widgets/widgets/workflow_console/index.tsx index dca3459c5..9282de0d2 100644 --- a/apps/tradinggoose/widgets/widgets/workflow_console/index.tsx +++ b/apps/tradinggoose/widgets/widgets/workflow_console/index.tsx @@ -3,6 +3,10 @@ import { Activity, ArrowDown, ArrowDownToLine, ArrowUp, Trash2 } from 'lucide-re import { JsonDisplayControls } from '@/components/json-display/json-display' import { LoadingAgent } from '@/components/ui/loading-agent' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { + widgetHeaderButtonGroupClassName, + widgetHeaderIconButtonClassName, +} from '@/components/widget-header-control' import { cn } from '@/lib/utils' import { useConsoleStore } from '@/stores/console/store' import { useWorkflowWidgetState } from '@/widgets/hooks/use-workflow-widget-state' @@ -12,10 +16,6 @@ import { emitWorkflowSelectionChange, useWorkflowSelectionPersistence, } from '@/widgets/utils/workflow-selection' -import { - widgetHeaderButtonGroupClassName, - widgetHeaderIconButtonClassName, -} from '@/widgets/widgets/components/widget-header-control' import { WorkflowDropdown } from '@/widgets/widgets/components/workflow-dropdown' import { FilterPopover } from './components/terminal/components/filter-popover' import { useWorkflowConsoleUiState } from './components/terminal/terminal-ui-store' diff --git a/apps/tradinggoose/widgets/widgets/workflow_variables/index.tsx b/apps/tradinggoose/widgets/widgets/workflow_variables/index.tsx index f227ea2e8..d95b01e8a 100644 --- a/apps/tradinggoose/widgets/widgets/workflow_variables/index.tsx +++ b/apps/tradinggoose/widgets/widgets/workflow_variables/index.tsx @@ -2,18 +2,18 @@ import { useCallback, useMemo } from 'react' import { Braces, Plus } from 'lucide-react' import { LoadingAgent } from '@/components/ui/loading-agent' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { widgetHeaderIconButtonClassName } from '@/components/widget-header-control' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { WORKFLOW_VARIABLES_ADD_EVENT } from '@/widgets/events' import { resolveWidgetChannel } from '@/widgets/hooks/use-widget-channel' import { useWorkflowWidgetState } from '@/widgets/hooks/use-workflow-widget-state' import type { WidgetInstance } from '@/widgets/layout' import type { DashboardWidgetDefinition, WidgetComponentProps } from '@/widgets/types' -import { widgetHeaderIconButtonClassName } from '@/widgets/widgets/components/widget-header-control' -import { WorkflowDropdown } from '@/widgets/widgets/components/workflow-dropdown' import { emitWorkflowSelectionChange, useWorkflowSelectionPersistence, } from '@/widgets/utils/workflow-selection' +import { WorkflowDropdown } from '@/widgets/widgets/components/workflow-dropdown' import WorkflowVariablesApp from './components/workflow-variables-app' const WidgetStateMessage = ({ message }: { message: string }) => ( @@ -122,9 +122,7 @@ const WorkflowVariablesHeaderActions = ({ return typeof value === 'string' && value.trim().length > 0 ? value : null }, [widget?.params]) - const activeWorkflowId = useWorkflowRegistry((state) => - state.getActiveWorkflowId(channelId) - ) + const activeWorkflowId = useWorkflowRegistry((state) => state.getActiveWorkflowId(channelId)) const resolvedWorkflowId = resolvedPairColor === 'gray' ? (paramsWorkflowId ?? activeWorkflowId) : activeWorkflowId diff --git a/changelog/May-29-2026.md b/changelog/May-29-2026.md new file mode 100644 index 000000000..098020683 --- /dev/null +++ b/changelog/May-29-2026.md @@ -0,0 +1,89 @@ +# May-29-2026 + +## feat/portfolio-monitor @ f38f79b4 vs upstream/staging + +### Summary +- Generalizes monitor management from indicator-only webhooks to source-aware indicator and portfolio monitors. +- Adds portfolio monitor configuration, condition evaluation, UI editing, trigger blocks, socket runtime subscriptions, and background workflow dispatch. +- Renames the trading holdings surface to portfolio detail across tools, blocks, API routes, provider capabilities, and broker context contracts. +- Consolidates monitor runtime locking, reconcile endpoints, workflow dispatch failure handling, and shared market/trading selector components. + +### Branch Scope +- Compared `42d33f424f140c4ad26d413a0f52c5576b4313ad..f38f79b46f2079b317ac93b8c9fda1b148bebe96`, where `42d33f424f140c4ad26d413a0f52c5576b4313ad` is both the merge base and current `upstream/staging`. +- Ran `git fetch upstream staging` before comparing. This entry intentionally uses `upstream/staging`, not the template default `origin/staging`, because the user requested the upstream base. +- `git status --short` was clean before this changelog edit, so no uncommitted feature work was included in the reviewed diff. The current uncommitted change is this dated changelog file only. +- Main areas touched: monitor API routes under `apps/tradinggoose/app/api/monitors/*`, monitor UI under `apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/*`, shared monitor contracts under `apps/tradinggoose/lib/monitors/*`, background monitor execution, socket monitor runtimes, workflow deployment/execution helpers, trading provider contracts, portfolio detail tools and blocks, shared provider selectors, and focused tests for those paths. + +### Key Changes +- `apps/tradinggoose/lib/monitors/sources.ts` introduces the canonical source contract for monitors: `INDICATOR_MONITOR_PROVIDER`, `PORTFOLIO_MONITOR_PROVIDER`, `INDICATOR_MONITOR_TRIGGER_ID`, `PORTFOLIO_MONITOR_TRIGGER_ID`, `MONITOR_SOURCES`, `MONITOR_WEBHOOK_PROVIDERS`, `MONITOR_TRIGGER_IDS`, `MonitorWebhookProvider`, `MonitorTriggerId`, `MonitorProviderConfigEnvelope`, and lookup/guard helpers such as `isMonitorProvider()`, `isMonitorTriggerId()`, `isMonitorProviderConfigForProvider()`, `getMonitorSourceByProvider()`, `getMonitorProviderForTriggerId()`, and `getMonitorTriggerIdForProvider()`. +- `apps/tradinggoose/lib/monitors/portfolio-config.ts` adds the portfolio monitor schema layer with `PortfolioMonitorCreateSchema`, `PortfolioMonitorUpdateSchema`, `PortfolioMonitorProviderConfigSchema`, `PortfolioFireConditionSchema`, `PortfolioMonitorProviderConfig`, `normalizePortfolioMonitorConfig()`, and `toPublicPortfolioMonitorProviderConfig()`. Portfolio configs carry `triggerId: portfolio_state_trigger`, `version: 1`, provider/service/credential/account fields, `connectionOwnerUserId`, `triggerBlockId`, condition data, fire/cooldown/poll settings, and runtime state. +- `apps/tradinggoose/lib/monitors/portfolio-conditions.ts` adds reusable portfolio condition types and evaluation helpers, including `PortfolioConditionSnapshot`, `PortfolioConditionMetric`, `PortfolioConditionOperator`, `PortfolioFireCondition`, `PORTFOLIO_CONDITION_METRICS`, `getPortfolioConditionOperatorsForMetric()`, `portfolioConditionRequiresListing()`, `isPortfolioConditionValuelessOperator()`, `isPortfolioConditionOperatorCompatible()`, and `evaluatePortfolioFireCondition()`. +- `apps/tradinggoose/app/api/monitors/route.ts`, `apps/tradinggoose/app/api/monitors/[id]/route.ts`, `apps/tradinggoose/app/api/monitors/shared.ts`, and `apps/tradinggoose/app/api/monitors/reconcile.ts` replace the indicator-specific monitor API. The new `/api/monitors` routes parse `source`, branch create/update validation through indicator or portfolio schemas, verify workspace/workflow access, check that the deployed workflow contains the expected monitor trigger block, resolve portfolio accounts, sanitize public provider configs, and notify the socket server through `/internal/monitors/reconcile`. +- Workflow deployment routes call `pauseMonitorsMissingDeployedTrigger()` and `notifyMonitorsReconcile()` from `apps/tradinggoose/app/api/monitors/shared.ts` and `apps/tradinggoose/app/api/monitors/reconcile.ts`. This extends the deployed-trigger pause/reconcile path from indicator webhooks to all monitor webhook providers. +- `apps/tradinggoose/triggers/blocks/portfolio_state_trigger.ts`, `apps/tradinggoose/triggers/portfolio/trigger.ts`, and `apps/tradinggoose/triggers/registry.ts` register the Portfolio Monitor trigger. The trigger advertises `webhookProvider: 'portfolio'` and emits portfolio identity/detail, monitor metadata, condition, and event payload fields for workflow runs. +- Monitor management UI is now source-aware across `apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/shared/types.ts`, `data/api.ts`, `data/use-monitor-reference-data.ts`, `editor/config-draft.ts`, `editor/monitor-editor-form.tsx`, `editor/monitor-editor-panel.tsx`, `kanban/config-card-model.ts`, and `kanban/config-board-state.ts`. Portfolio monitors can select trading provider/service/account settings, edit portfolio conditions through `portfolio-condition-builder.tsx`, choose fire/cooldown/poll settings, and share the same board and optimistic-update paths as indicator monitors. +- `apps/tradinggoose/socket-server/trading/portfolio-monitor-runtime.ts` adds the portfolio monitor runtime. It reconciles active portfolio webhooks, parses `PortfolioMonitorProviderConfigSchema`, subscribes through `tradingPortfolioStreamManager.subscribeData()`, evaluates `evaluatePortfolioFireCondition()`, handles edge and while-true fire modes, persists runtime state into provider config, enforces cooldowns, and enqueues `executionType: 'monitor'` pending executions with source `monitor:portfolio`. +- `apps/tradinggoose/socket-server/market/indicator-monitor-runtime.ts` was adapted to the shared source contract and `apps/tradinggoose/socket-server/monitor-runtime-lock.ts` now owns reusable fail-closed Redis runtime locking and health reporting for monitor runtimes. `apps/tradinggoose/socket-server/index.ts` starts/stops both indicator and portfolio runtimes, and `apps/tradinggoose/socket-server/routes/http.ts` reports both runtimes in `/health` and accepts `/internal/monitors/reconcile`. +- `apps/tradinggoose/background/monitor-execution.ts`, `apps/tradinggoose/background/portfolio-monitor-execution.ts`, `apps/tradinggoose/background/indicator-monitor-execution.ts`, `apps/tradinggoose/background/pending-execution-drain.ts`, and `apps/tradinggoose/lib/execution/pending-execution.ts` convert queued monitor work to a generic `PendingExecutionType` of `monitor`. The dispatcher routes portfolio and indicator payloads by `source`, portfolio executions start workflow runs at the trigger block, and permanent dispatch failures disable the source-specific monitor through `apps/tradinggoose/background/monitor-disable.ts`. +- `apps/tradinggoose/lib/workflows/execution-runner.ts` now returns `dispatchFailureReason` for usage-limit and missing-start-block failures, and `apps/tradinggoose/background/workflow-execution.ts` disables monitor webhooks when queued monitor-triggered workflow dispatch fails permanently. The workflow start path also honors `payload.startBlockId` for deployed and live workflow executions. +- Monitor log filtering and snapshots now accept all monitor trigger ids. `apps/tradinggoose/app/api/logs/route.ts`, `apps/tradinggoose/app/api/logs/export/route.ts`, `apps/tradinggoose/app/api/v1/logs/filters.ts`, `apps/tradinggoose/app/api/v1/logs/route.ts`, `apps/tradinggoose/app/api/logs/log-utils.ts`, `apps/tradinggoose/hooks/queries/logs.ts`, and `apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/data/use-monitor-workspace-logs.ts` support comma-separated monitor trigger sources and expose portfolio-specific service/account context in monitor log records. +- Trading holdings were renamed to portfolio detail across `apps/tradinggoose/lib/trading/portfolio-detail.ts`, `apps/tradinggoose/app/api/tools/trading/portfolio-detail/route.ts`, `apps/tradinggoose/tools/trading/portfolio-detail.ts`, and `apps/tradinggoose/blocks/blocks/portfolio_detail.ts`. The tool id is now `trading_get_portfolio_detail`, the block type is `portfolio_detail`, and output fields use `portfolioDetail`. +- Trading identity and provider contracts were tightened in `apps/tradinggoose/lib/trading/context.ts`, `apps/tradinggoose/lib/trading/portfolio-identities.ts`, `apps/tradinggoose/providers/trading/types.ts`, provider configs, account mappers, and portfolio detail providers. Trading operations now use `portfolioDetail` capability names, `PortfolioIdentity` carries `credentialId`, provider context preserves `tokenAccountId` internally, and positions expose structured `listingIdentity`. +- Shared selector components moved under `apps/tradinggoose/components/*`. `apps/tradinggoose/components/provider-selector.tsx`, `components/market-selector/*`, `components/trading-selector/*`, `components/oauth/oauth-required-modal.tsx`, and `components/widget-header-control.ts` replace widget-local provider, OAuth, and header-control implementations. +- Workflow editor sub-block contracts now include `market-provider-selector`, `trading-provider-selector`, and `trading-account-selector` through `apps/tradinggoose/blocks/types.ts`, `apps/tradinggoose/tools/params.ts`, `apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/sub-block.tsx`, and `use-depends-on-gate.ts`. `historical_data`, `trading_action`, and `portfolio_detail` blocks use the shared selectors instead of inline provider-account option logic. +- `apps/tradinggoose/socket-server/trading/portfolio-manager.ts` adds `subscribeData()` for non-socket consumers, per-subscription `pollIntervalSeconds`, account snapshot delivery, and deterministic unsubscribe/refresh behavior. The portfolio monitor runtime reuses this stream manager instead of duplicating broker polling. + +### Design Decisions +- Monitor source and trigger identity are centralized in `apps/tradinggoose/lib/monitors/sources.ts` so API routes, triggers, runtimes, background jobs, logs, and deployment reconciliation can share one provider/trigger map instead of branching on string literals. +- Portfolio monitor provider config is stored as a versioned envelope with `triggerId`, account identity, condition settings, and runtime state. This keeps durable monitor configuration, workflow trigger targeting, and runtime edge/cooldown state together without adding a parallel monitor-specific table. +- Portfolio condition evaluation lives in `apps/tradinggoose/lib/monitors/portfolio-conditions.ts`, not in the editor UI or socket runtime. The UI, runtime, and future tools can reuse one metric/operator compatibility contract. +- The socket portfolio monitor runtime subscribes to `TradingPortfolioStreamManager` instead of calling broker providers directly. That keeps broker polling, account snapshots, refresh behavior, and subscription cleanup owned by the existing socket portfolio stream layer. +- Queued monitor work now uses a single pending execution type, `monitor`, with payload-level source dispatch. This avoids adding a new queue execution type for every monitor source while still preserving source-specific payload validation. +- Runtime locks are fail-closed and shared through `createMonitorRuntimeLock()` so multiple socket server instances do not run duplicate monitor dispatch loops when Redis locking is unavailable or degraded. +- Permanent workflow dispatch failures disable monitor webhooks through `disableMonitor()` because a monitor that cannot dispatch its configured trigger block should stop retrying until the deployed workflow/config is corrected. +- Trading terminology now uses `portfolioDetail` instead of `holdings` because the returned broker payload includes account summary, positions, balances, listing identity metadata, and provider context rather than only holdings rows. +- Market and trading provider selectors moved into shared `apps/tradinggoose/components/*` ownership because widgets, workflow blocks, monitor forms, and tools all need the same connected-provider and account-selection behavior. + +### Shared Contracts and Helpers to Reuse +- Reuse monitor source constants, types, and helpers from `apps/tradinggoose/lib/monitors/sources.ts`. Do not hardcode `indicator`, `portfolio`, `indicator_trigger`, or `portfolio_state_trigger` in new API, runtime, log, or workflow code. +- Reuse `PortfolioMonitorCreateSchema`, `PortfolioMonitorUpdateSchema`, `PortfolioMonitorProviderConfigSchema`, `normalizePortfolioMonitorConfig()`, and `toPublicPortfolioMonitorProviderConfig()` from `apps/tradinggoose/lib/monitors/portfolio-config.ts` for every portfolio monitor create, update, runtime parse, and response sanitization path. +- Reuse `PortfolioFireCondition`, `PortfolioConditionSnapshot`, `getPortfolioConditionOperatorsForMetric()`, `portfolioConditionRequiresListing()`, and `evaluatePortfolioFireCondition()` from `apps/tradinggoose/lib/monitors/portfolio-conditions.ts` for portfolio monitor editors, runtime execution, tests, and any future condition preview. +- Reuse monitor API helpers from `apps/tradinggoose/app/api/monitors/shared.ts`, especially `listMonitorRows()`, `getMonitorRowById()`, `ensureMonitorTriggerBlockInDeployedState()`, `ensureWorkflowInWorkspace()`, `resolvePortfolioMonitorAccount()`, `ensureTriggerCapableIndicator()`, `loadIndicatorInputMetadata()`, `toMonitorRecord()`, and `pauseMonitorsMissingDeployedTrigger()`. +- Reuse `notifyMonitorsReconcile()` from `apps/tradinggoose/app/api/monitors/reconcile.ts` after any monitor, deployment, activation, or revert operation that should refresh socket runtime subscriptions. +- Reuse `createMonitorRuntimeLock()`, `getMonitorRuntimeLockHealth()`, `getMonitorRuntimeUnavailableStatus()`, and `isMonitorRuntimeDatabaseConnectionError()` from `apps/tradinggoose/socket-server/monitor-runtime-lock.ts` for monitor runtimes instead of creating runtime-specific Redis lock handling. +- Reuse the generic background monitor dispatcher in `apps/tradinggoose/background/monitor-execution.ts` and source-specific payload guards in `indicator-monitor-execution.ts` and `portfolio-monitor-execution.ts` when adding new monitor execution paths. +- Reuse `disableMonitor()` from `apps/tradinggoose/background/monitor-disable.ts` when a monitor provider should be deactivated after a permanent failure. +- Reuse `TradingPortfolioStreamManager.subscribeData()` from `apps/tradinggoose/socket-server/trading/portfolio-manager.ts` for non-socket portfolio account snapshots instead of polling broker providers directly. +- Reuse `getTradingPortfolioDetail()`, `TradingPortfolioDetailRequest`, and `TradingPortfolioDetailResult` from `apps/tradinggoose/lib/trading/portfolio-detail.ts` for route, tool, workflow, and monitor paths that need broker portfolio details. +- Reuse `authorizeTradingConnectionRequest()` and `resolveTradingProviderContext()` from `apps/tradinggoose/lib/trading/context.ts` for trading broker operations so credential ownership, service matching, and token account resolution stay centralized. +- Reuse `apps/tradinggoose/components/provider-selector.tsx`, `components/market-selector/*`, `components/trading-selector/*`, and `components/oauth/oauth-required-modal.tsx` for provider/account UI in widgets, monitor forms, and workflow sub-blocks. +- Reuse the workflow sub-block types `market-provider-selector`, `trading-provider-selector`, and `trading-account-selector` from `apps/tradinggoose/blocks/types.ts` and the renderer implementations under `apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/*`. + +### Removed or Replaced Items +- Deleted `apps/tradinggoose/app/api/indicator-monitors/route.ts` and `apps/tradinggoose/app/api/indicator-monitors/[id]/route.ts`. Use `apps/tradinggoose/app/api/monitors/route.ts` and `apps/tradinggoose/app/api/monitors/[id]/route.ts` with an explicit monitor `source`. +- Replaced `apps/tradinggoose/app/api/indicator-monitors/shared.ts` and `apps/tradinggoose/app/api/indicator-monitors/reconcile.ts` with source-aware helpers in `apps/tradinggoose/app/api/monitors/shared.ts` and `apps/tradinggoose/app/api/monitors/reconcile.ts`. +- Replaced `/internal/indicator-monitors/reconcile` with `/internal/monitors/reconcile` in `apps/tradinggoose/socket-server/routes/http.ts`. New reconcile callers should use `notifyMonitorsReconcile()`. +- Replaced `PendingExecutionType` value `indicator_monitor` with `monitor` in `apps/tradinggoose/lib/execution/pending-execution.ts`. Source-specific behavior belongs in the monitor payload and `apps/tradinggoose/background/monitor-execution.ts`. +- Replaced `apps/tradinggoose/lib/trading/holdings.ts`, `apps/tradinggoose/app/api/tools/trading/holdings/route.ts`, `apps/tradinggoose/tools/trading/holdings.ts`, and `apps/tradinggoose/blocks/blocks/trading_holdings.ts` with the portfolio-detail equivalents. Do not reintroduce `trading_get_holdings`, `trading_holdings`, or `/api/tools/trading/holdings`. +- Replaced trading provider capability names and operation kind `holdings` with `portfolioDetail` in provider configs and `apps/tradinggoose/providers/trading/types.ts`. Future provider work should expose portfolio detail capability metadata, not holdings-specific names. +- Replaced public position `symbol` output with structured `listingIdentity` on `UnifiedTradingPosition`. New consumers should use listing identity helpers instead of broker-local symbols. +- Replaced widget-local provider selectors and OAuth modal paths, including `apps/tradinggoose/widgets/widgets/components/trading-provider-selector.tsx` and old watchlist provider controls, with shared components under `apps/tradinggoose/components/*`. +- Removed the workflow save behavior that nulled indicator webhook `blockId` in `apps/tradinggoose/lib/workflows/db-helpers.ts`. Monitor trigger targeting now belongs in provider config `triggerBlockId`. + +### Future Branch Guardrails +- Do not add new monitor code under `app/api/indicator-monitors` or call `/api/indicator-monitors`. The API surface is `/api/monitors` with source-aware validation. +- Do not introduce raw monitor provider or trigger string comparisons. Import from `apps/tradinggoose/lib/monitors/sources.ts` and use its guard/lookup helpers. +- Do not store portfolio monitor runtime edge/cooldown state outside the provider config envelope unless a future migration intentionally changes the monitor persistence model. +- Do not enqueue source-specific pending execution types for monitor providers. Use `executionType: 'monitor'`, set an explicit payload `source`, and route through `executeMonitorJob()`. +- Do not bypass `TradingPortfolioStreamManager.subscribeData()` for portfolio monitor snapshots. Runtime consumers should subscribe to the shared stream manager so broker polling and refresh behavior stay consistent with socket clients. +- Do not bring back holdings naming in blocks, tools, routes, provider capabilities, or tests. Use `portfolioDetail` and the `portfolio_detail` block/tool contracts. +- Do not duplicate provider/account selector implementations under widgets or monitor components. Use the shared selector components under `apps/tradinggoose/components/*` and the workflow sub-block renderers. +- Do not disable monitor webhooks by mutating source-specific rows directly. Use `disableMonitor()` so logging, provider selection, and update behavior remain consistent. +- Keep workflow deployment, activation, and revert paths calling `pauseMonitorsMissingDeployedTrigger()` and `notifyMonitorsReconcile()` so socket runtimes track deployed trigger availability. + +### Validation Notes +- Used the requested `staging-changelog` skill and followed `changelog/TEMPLATE.md`, with the explicit base override from `origin/staging` to `upstream/staging`. +- Reviewed `git status --short`, `git remote -v`, `git fetch upstream staging`, `git rev-parse feat/portfolio-monitor`, `git rev-parse upstream/staging`, `git merge-base upstream/staging feat/portfolio-monitor`, `git log --oneline 42d33f424f140c4ad26d413a0f52c5576b4313ad..feat/portfolio-monitor`, `git diff --stat`, `git diff --name-status --find-renames`, and targeted `git diff --unified` output for the branch range. +- Confirmed the reviewed branch diff touched no `*/migration/*` files. +- Inspected monitor source/config/condition contracts, generic monitor API routes, deployment reconcile helpers, trigger registration, monitor UI editor and board code, background execution dispatchers, pending execution drain code, workflow execution failure handling, socket runtime locks, indicator and portfolio monitor runtimes, socket health/reconcile endpoints, log filters, trading portfolio detail routes/tools/blocks, provider contracts, shared selector components, and related focused tests. +- No automated test suite was run for this changelog-only update; validation focused on command-backed diff review, source inspection, and template conformance. From 9e1f14432bfbe419d41b76b0e10e0339bbecade6 Mon Sep 17 00:00:00 2001 From: Bruzzz BackUp <149516937+BWJ2310-backup@users.noreply.github.com> Date: Fri, 29 May 2026 19:30:05 -0600 Subject: [PATCH 4/5] fix(monitors): filter monitor blocks in SQL and reduce runtime state writes (#131) * fix(monitor-runtime): refine runtime state update logic in PortfolioMonitorRuntime * fix(portfolio-monitor): remove unused previousSnapshot schema and update runtime state handling * fix(portfolio-monitor): update PortfolioMonitorProviderConfigSchema to use .strip() and .catch(undefined) --- apps/tradinggoose/app/api/monitors/shared.ts | 17 +++++++--------- .../lib/monitors/portfolio-config.ts | 13 +++--------- .../trading/portfolio-monitor-runtime.ts | 20 +++++++++++-------- 3 files changed, 22 insertions(+), 28 deletions(-) diff --git a/apps/tradinggoose/app/api/monitors/shared.ts b/apps/tradinggoose/app/api/monitors/shared.ts index 23880d7e5..a2e0b5b5b 100644 --- a/apps/tradinggoose/app/api/monitors/shared.ts +++ b/apps/tradinggoose/app/api/monitors/shared.ts @@ -5,7 +5,7 @@ import { workflow, workflowDeploymentVersion, } from '@tradinggoose/db/schema' -import { and, desc, eq, inArray } from 'drizzle-orm' +import { and, desc, eq, inArray, sql } from 'drizzle-orm' import { DEFAULT_INDICATOR_RUNTIME_MAP } from '@/lib/indicators/default/runtime' import { normalizeInputMetaMap } from '@/lib/indicators/input-meta' import { @@ -85,6 +85,11 @@ export const listMonitorRows = async ({ if (workflowId) { conditions.push(eq(webhook.workflowId, workflowId)) } + + if (blockId) { + conditions.push(sql`${webhook.providerConfig}->'monitor'->>'triggerBlockId' = ${blockId}`) + } + const rows = await db .select({ webhook: webhook, @@ -97,15 +102,7 @@ export const listMonitorRows = async ({ .innerJoin(workflow, eq(webhook.workflowId, workflow.id)) .where(and(...conditions)) .orderBy(desc(webhook.updatedAt)) - - if (!blockId) return rows - return rows.filter((row) => { - if (!isMonitorProvider(row.webhook.provider)) return false - return ( - getTriggerBlockIdFromMonitorConfig(row.webhook.providerConfig, row.webhook.provider) === - blockId - ) - }) + return rows } export const getMonitorRowById = async (id: string) => { diff --git a/apps/tradinggoose/lib/monitors/portfolio-config.ts b/apps/tradinggoose/lib/monitors/portfolio-config.ts index 6890ec29b..4f0cc885d 100644 --- a/apps/tradinggoose/lib/monitors/portfolio-config.ts +++ b/apps/tradinggoose/lib/monitors/portfolio-config.ts @@ -13,12 +13,6 @@ import type { TradingProviderId } from '@/providers/trading/types' const nonEmptyString = z.string().trim().min(1) const tradingProviderId = nonEmptyString.transform((value) => value as TradingProviderId) -const PortfolioConditionSnapshotSchema = z - .object({ - summary: z.unknown(), - positions: z.array(z.unknown()), - }) - .strict() const PortfolioConditionRuleSchema: z.ZodType = z .object({ @@ -117,13 +111,12 @@ export const PortfolioMonitorProviderConfigSchema = z .strict(), runtimeState: z .object({ - lastEvaluatedAt: z.string().optional(), lastFiredAt: z.string().optional(), wasTrue: z.boolean().optional(), - previousSnapshot: PortfolioConditionSnapshotSchema.optional(), }) - .strict() - .optional(), + .strip() + .optional() + .catch(undefined), }) .strict() diff --git a/apps/tradinggoose/socket-server/trading/portfolio-monitor-runtime.ts b/apps/tradinggoose/socket-server/trading/portfolio-monitor-runtime.ts index 7a659c69b..ff965e660 100644 --- a/apps/tradinggoose/socket-server/trading/portfolio-monitor-runtime.ts +++ b/apps/tradinggoose/socket-server/trading/portfolio-monitor-runtime.ts @@ -77,6 +77,7 @@ type PortfolioMonitorRuntimeConfig = { type PortfolioMonitorSubscription = { config: PortfolioMonitorRuntimeConfig + previousSnapshot?: PortfolioConditionSnapshot unsubscribe: () => void } @@ -413,32 +414,34 @@ export class PortfolioMonitorRuntime { summary: currentDetail.summary, positions: currentDetail.positions, } - const previousSnapshot = config.runtimeState?.previousSnapshot as - | PortfolioConditionSnapshot - | undefined - const previousWasTrue = config.runtimeState?.wasTrue + const previousSnapshot = subscription.previousSnapshot + const previousWasTrue = config.runtimeState?.wasTrue === true const previousLastFiredAt = config.runtimeState?.lastFiredAt const conditionMatched = evaluatePortfolioFireCondition({ condition: config.condition, current: currentSnapshot, previous: previousSnapshot, }) - const crossedEdge = conditionMatched && previousWasTrue !== true + const crossedEdge = conditionMatched && !previousWasTrue const shouldFire = conditionMatched && (config.fireMode === 'while_true' || crossedEdge) && isCooldownOpen(previousLastFiredAt, config.cooldownSeconds) const evaluatedAt = new Date().toISOString() const evaluatedState: PortfolioMonitorProviderConfig['runtimeState'] = { - lastEvaluatedAt: evaluatedAt, lastFiredAt: previousLastFiredAt, wasTrue: conditionMatched, - previousSnapshot: currentSnapshot, } if (!shouldFire) { - if (await this.updateRuntimeState(config, evaluatedState)) { + if (previousWasTrue !== conditionMatched) { + if (await this.updateRuntimeState(config, evaluatedState)) { + config.runtimeState = evaluatedState + subscription.previousSnapshot = currentSnapshot + } + } else { config.runtimeState = evaluatedState + subscription.previousSnapshot = currentSnapshot } return } @@ -509,6 +512,7 @@ export class PortfolioMonitorRuntime { } if (await this.updateRuntimeState(config, firedState)) { config.runtimeState = firedState + subscription.previousSnapshot = currentSnapshot } } From 7bb864565e6024323b88f52bb63ae43e31ded7bd Mon Sep 17 00:00:00 2001 From: Bruzzz BackUp <149516937+BWJ2310-backup@users.noreply.github.com> Date: Fri, 29 May 2026 20:11:13 -0600 Subject: [PATCH 5/5] fix(watchlist): fetch individual watchlist items by id (#132) * feat(watchlist): add support for retrieving individual watchlists and update related tests * fix(tests): update dynamic internal API URL to include filter and workspaceId --- apps/tradinggoose/app/api/watchlists/route.ts | 7 +++++++ apps/tradinggoose/tools/index.test.ts | 14 ++++++++++---- .../tradinggoose/tools/watchlist/index.test.ts | 18 ++++++++---------- apps/tradinggoose/tools/watchlist/index.ts | 17 +++++------------ 4 files changed, 30 insertions(+), 26 deletions(-) diff --git a/apps/tradinggoose/app/api/watchlists/route.ts b/apps/tradinggoose/app/api/watchlists/route.ts index 60e7b0e09..1a50e8e62 100644 --- a/apps/tradinggoose/app/api/watchlists/route.ts +++ b/apps/tradinggoose/app/api/watchlists/route.ts @@ -5,6 +5,7 @@ import { createLogger } from '@/lib/logs/console/logger' import { getUserEntityPermissions } from '@/lib/permissions/utils' import { createWatchlist, + getWatchlist, listWatchlists, WatchlistOperationError, } from '@/lib/watchlists/operations' @@ -63,6 +64,12 @@ export async function GET(request: NextRequest) { await requireWorkspacePermission(userId, workspaceId) + const watchlistId = request.nextUrl.searchParams.get('watchlistId')?.trim() + if (watchlistId) { + const watchlist = await getWatchlist({ workspaceId, userId }, watchlistId) + return NextResponse.json({ watchlist }, { status: 200 }) + } + const watchlists = await listWatchlists({ workspaceId, userId, diff --git a/apps/tradinggoose/tools/index.test.ts b/apps/tradinggoose/tools/index.test.ts index 0e9d1f6a6..db9715eaa 100644 --- a/apps/tradinggoose/tools/index.test.ts +++ b/apps/tradinggoose/tools/index.test.ts @@ -729,7 +729,7 @@ describe('Automatic Internal Route Detection', () => { resourceId: { type: 'string', required: true }, }, request: { - url: (params: any) => `/api/resources/${params.resourceId}`, + url: (params: any) => `/api/resources/${params.resourceId}?filter=active`, method: 'GET', headers: () => ({ 'Content-Type': 'application/json' }), }, @@ -746,8 +746,10 @@ describe('Automatic Internal Route Detection', () => { // Mock fetch for the internal API call global.fetch = Object.assign( vi.fn().mockImplementation(async (url) => { - // Should call the internal API directly with the resolved dynamic URL - expect(url).toBe('http://localhost:3000/api/resources/123') + // Dynamic internal URLs use the same centralized context query handling as static URLs. + expect(url).toBe( + 'http://localhost:3000/api/resources/123?filter=active&workspaceId=workspace-1' + ) const responseData = { success: true, data: 'test' } return { ok: true, @@ -762,7 +764,11 @@ describe('Automatic Internal Route Detection', () => { { preconnect: vi.fn() } ) as typeof fetch - const result = await executeTool('test_dynamic_internal', { resourceId: '123' }, false) + const result = await executeTool( + 'test_dynamic_internal', + { resourceId: '123', _context: { workspaceId: 'workspace-1' } }, + false + ) expect(result.success).toBe(true) expect(result.output.result).toBe('Dynamic internal route success') diff --git a/apps/tradinggoose/tools/watchlist/index.test.ts b/apps/tradinggoose/tools/watchlist/index.test.ts index edb3e37ca..b7dc02aea 100644 --- a/apps/tradinggoose/tools/watchlist/index.test.ts +++ b/apps/tradinggoose/tools/watchlist/index.test.ts @@ -48,18 +48,16 @@ describe('watchlist tools', () => { }) }) - it('maps read-list-items from the canonical watchlists response', async () => { + it('maps read-list-items from the canonical watchlist response', async () => { const response = new Response( JSON.stringify({ - watchlists: [ - { - id: 'watchlist-1', - items: [ - { id: 'section-1', type: 'section', label: 'Tech' }, - { id: 'listing-1', type: 'listing', listing }, - ], - }, - ], + watchlist: { + id: 'watchlist-1', + items: [ + { id: 'section-1', type: 'section', label: 'Tech' }, + { id: 'listing-1', type: 'listing', listing }, + ], + }, }) ) diff --git a/apps/tradinggoose/tools/watchlist/index.ts b/apps/tradinggoose/tools/watchlist/index.ts index 5e8d4ceb2..bf12d609d 100644 --- a/apps/tradinggoose/tools/watchlist/index.ts +++ b/apps/tradinggoose/tools/watchlist/index.ts @@ -83,16 +83,6 @@ const transformReadListsResponse = async ( output: (await response.json()) as WatchlistListsOutput, }) -const transformReadListItemsResponse = async ( - response: Response, - params?: WatchlistReadListItemsParams -): Promise> => { - const { watchlists } = (await response.json()) as WatchlistListsOutput - const watchlist = watchlists.find((entry) => entry.id === params?.watchlistId) - if (!watchlist) throw new Error('Watchlist not found') - return { success: true, output: watchlistOutput(watchlist) } -} - const transformWatchlistResponse = async ( response: Response ): Promise> => { @@ -164,8 +154,11 @@ export const watchlistReadListItemsTool: ToolConfig< params: { watchlistId: watchlistIdParam, }, - request: readWatchlistsRequest, - transformResponse: transformReadListItemsResponse, + request: { + ...readWatchlistsRequest, + url: (params) => `/api/watchlists?watchlistId=${encodeURIComponent(params.watchlistId)}`, + }, + transformResponse: transformWatchlistResponse, outputs: watchlistListItemsOutputs, }