+
{recent.map((h, i) => {
const action = (h.action || '').toUpperCase()
const row = (
@@ -648,7 +648,7 @@ export function ToolkitDiagnostics({
)}
{h.snippet && (
-
+
{h.snippet}
{h.chars != null && h.chars > h.snippet.length && (
diff --git a/frontend/packages/biz-ui/src/components/logs-modal.tsx b/frontend/packages/biz-ui/src/components/logs-modal.tsx
index 946625fe4..63b945969 100644
--- a/frontend/packages/biz-ui/src/components/logs-modal.tsx
+++ b/frontend/packages/biz-ui/src/components/logs-modal.tsx
@@ -1,3 +1,4 @@
+import { useConfirm } from '@panwatch/base-ui/components/ui/confirm-dialog'
import { useEffect, useMemo, useRef, useState, useCallback } from 'react'
import { Search, Trash2, RefreshCw, ScrollText, ChevronDown } from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@panwatch/base-ui/components/ui/dialog'
@@ -79,6 +80,7 @@ function unique(arr: string[]) {
export default function LogsModal({ open, onOpenChange }: { open: boolean, onOpenChange: (v: boolean) => void }) {
const { t, i18n } = useTranslation('bizUi')
+ const confirmAction = useConfirm()
const tr = (key: string, options?: Record) =>
(t as unknown as (key: string, options?: Record) => string)(`logs.${key}`, options)
const locale = (i18n.resolvedLanguage || i18n.language).toLowerCase().startsWith('en') ? 'en-US' : 'zh-CN'
@@ -241,7 +243,7 @@ export default function LogsModal({ open, onOpenChange }: { open: boolean, onOpe
}
const handleClear = async () => {
- if (!confirm(tr('clearConfirm'))) return
+ if (!(await confirmAction(tr('clearConfirm'), { destructive: true }))) return
await fetchAPI('/logs', { method: 'DELETE' })
setLogs([])
setTotal(0)
diff --git a/frontend/packages/biz-ui/src/components/stock-insight-modal.tsx b/frontend/packages/biz-ui/src/components/stock-insight-modal.tsx
index 13ca6b500..7e7cf30e9 100644
--- a/frontend/packages/biz-ui/src/components/stock-insight-modal.tsx
+++ b/frontend/packages/biz-ui/src/components/stock-insight-modal.tsx
@@ -1705,25 +1705,25 @@ export default function StockInsightModal(props: {
{activeReport.prompt_stats ? (
{tr('reports.promptStats')}
-
{JSON.stringify(activeReport.prompt_stats, null, 2)}
+
{JSON.stringify(activeReport.prompt_stats, null, 2)}
) : null}
{activeReport.news_debug ? (
{tr('reports.newsDetails')}
-
{JSON.stringify(activeReport.news_debug, null, 2)}
+
{JSON.stringify(activeReport.news_debug, null, 2)}
) : null}
{activeReport.context_payload ? (
{tr('reports.contextSnapshot')}
-
{JSON.stringify(activeReport.context_payload, null, 2)}
+
{JSON.stringify(activeReport.context_payload, null, 2)}
) : null}
{activeReport.prompt_context ? (
{tr('reports.promptOriginal')}
-
{activeReport.prompt_context}
+
{activeReport.prompt_context}
) : null}
@@ -2033,7 +2033,7 @@ function DeepAnalysisSection({
{showDebate ? '▼' : '▶'} {tr('deep.debate')}
{showDebate && (
-
+
{debate.history}
{debate.judge_decision && (
<>
@@ -2105,7 +2105,7 @@ function DeepHistoryComparison({
{fmtRet(stats.avg_return_20d_pct)}
-
+
diff --git a/frontend/packages/biz-ui/src/components/stock-price-alert-panel.tsx b/frontend/packages/biz-ui/src/components/stock-price-alert-panel.tsx
index 0cc1027c1..c7dcd3ac5 100644
--- a/frontend/packages/biz-ui/src/components/stock-price-alert-panel.tsx
+++ b/frontend/packages/biz-ui/src/components/stock-price-alert-panel.tsx
@@ -1,3 +1,4 @@
+import { useConfirm } from '@panwatch/base-ui/components/ui/confirm-dialog'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Bell, Plus, Pencil, Trash2 } from 'lucide-react'
import { fetchAPI, stocksApi, type NotifyChannel } from '@panwatch/api'
@@ -73,6 +74,7 @@ export default function StockPriceAlertPanel(props: {
}) {
const { toast } = useToast()
const { t } = useTranslation('bizUi')
+ const confirmAction = useConfirm()
const tr = (key: string, options?: Record) =>
(t as unknown as (key: string, options?: Record) => string)(`stockPriceAlert.${key}`, options)
const symbol = String(props.symbol || '').trim()
@@ -236,7 +238,7 @@ export default function StockPriceAlertPanel(props: {
}
const removeRule = async (r: AlertRule) => {
- if (!window.confirm(tr('messages.deleteConfirm', { name: r.name || tr('alert') }))) return
+ if (!(await confirmAction(tr('messages.deleteConfirm', { name: r.name || tr('alert') }), { destructive: true }))) return
try {
await fetchAPI(`/price-alerts/${r.id}`, { method: 'DELETE' })
await load()
diff --git a/frontend/packages/biz-ui/src/components/suggestion-badge.tsx b/frontend/packages/biz-ui/src/components/suggestion-badge.tsx
index b6d1e898e..a8cefd6e0 100644
--- a/frontend/packages/biz-ui/src/components/suggestion-badge.tsx
+++ b/frontend/packages/biz-ui/src/components/suggestion-badge.tsx
@@ -572,7 +572,7 @@ export function SuggestionBadge({
{suggestion.ai_response && (
{tr('aiResponse')}
-
+
{suggestion.ai_response}
@@ -584,7 +584,7 @@ export function SuggestionBadge({
{tr('promptContext')} {tr('expand')}
-
+
{suggestion.prompt_context}
diff --git a/frontend/scripts/check-ui-primitives.mjs b/frontend/scripts/check-ui-primitives.mjs
new file mode 100644
index 000000000..4d8ed6059
--- /dev/null
+++ b/frontend/scripts/check-ui-primitives.mjs
@@ -0,0 +1,59 @@
+import fs from 'node:fs'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+import ts from 'typescript'
+
+const frontendRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
+const sourceRoots = ['src', 'packages/base-ui/src', 'packages/biz-ui/src']
+const nativeDialogs = new Set(['confirm', 'alert', 'prompt'])
+const scrollClass = /\boverflow(?:-[xy])?-(?:auto|scroll)\b/
+
+export function findUiViolations(source, filename = 'component.tsx') {
+ const ast = ts.createSourceFile(filename, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
+ const failures = []
+ const report = (node, message) => {
+ const { line } = ast.getLineAndCharacterOfPosition(node.getStart(ast))
+ failures.push(`${filename}:${line + 1} ${message}`)
+ }
+ const visit = node => {
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && nativeDialogs.has(node.expression.text)) {
+ report(node, 'Use shared UI instead of a native browser dialog')
+ }
+ if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression)
+ && ['window', 'globalThis'].includes(node.expression.text) && nativeDialogs.has(node.name.text)) {
+ report(node, 'Use shared UI instead of a native browser dialog')
+ }
+ if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
+ const tag = node.tagName.getText(ast)
+ if (['select', 'option', 'dialog'].includes(tag)) report(node, 'Use shared Select or Dialog components')
+ if (/^[a-z]/.test(tag)) {
+ const attribute = node.attributes.properties.find(value => ts.isJsxAttribute(value) && value.name.getText(ast) === 'className')
+ const classes = attribute?.initializer?.getText(ast) ?? ''
+ if (scrollClass.test(classes) && !/\bscrollbar(?:-none)?\b/.test(classes)) {
+ report(attribute, 'Scrollable regions need scrollbar (or intentional scrollbar-none)')
+ }
+ }
+ }
+ ts.forEachChild(node, visit)
+ }
+ visit(ast)
+ return failures
+}
+
+function collect(directory) {
+ return fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
+ const absolute = path.join(directory, entry.name)
+ if (entry.isDirectory()) return collect(absolute)
+ return /\.(ts|tsx)$/.test(entry.name) && !/\.(test|spec)\.(ts|tsx)$/.test(entry.name) ? [absolute] : []
+ })
+}
+
+if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ const failures = sourceRoots.flatMap(root => collect(path.join(frontendRoot, root))).sort().flatMap(file =>
+ findUiViolations(fs.readFileSync(file, 'utf8'), path.relative(frontendRoot, file)))
+ if (failures.length) {
+ console.error('UI primitive check failed. See UI_GUIDELINES.md:')
+ failures.forEach(failure => console.error(`- ${failure}`))
+ process.exitCode = 1
+ } else console.log('UI primitive check passed')
+}
diff --git a/frontend/src/components/FactorWeightsPanel.tsx b/frontend/src/components/FactorWeightsPanel.tsx
index 5301542d6..8af799219 100644
--- a/frontend/src/components/FactorWeightsPanel.tsx
+++ b/frontend/src/components/FactorWeightsPanel.tsx
@@ -92,7 +92,7 @@ export default function FactorWeightsPanel() {
) : sortedItems.length === 0 ? (
{tr('empty')}
) : (
-
+
diff --git a/frontend/src/components/MarketCalendarStatus.tsx b/frontend/src/components/MarketCalendarStatus.tsx
new file mode 100644
index 000000000..5ba390186
--- /dev/null
+++ b/frontend/src/components/MarketCalendarStatus.tsx
@@ -0,0 +1,163 @@
+import { useEffect, useState } from 'react'
+import { CalendarDays, ChevronDown, X } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+import { fetchAPI } from '@panwatch/api'
+import { Popover, PopoverContent, PopoverTrigger } from '@panwatch/base-ui/components/ui/popover'
+import { Button } from '@panwatch/base-ui/components/ui/button'
+import { getCurrentLocale } from '@/i18n'
+
+export interface MarketStatus {
+ code: string
+ status: string
+ status_text: string
+ is_trading: boolean
+ sessions: string[]
+ local_time: string
+ local_date?: string
+ timezone: string
+}
+
+interface CalendarDay {
+ date: string
+ is_trading_day: boolean | null
+ reason: string
+ session_times: { open: string; close: string }[]
+}
+interface TradingCalendars {
+ timezone: string
+ markets: {
+ market: string; status: string; next_open: string | null
+ local_date: string; local_time: string; timezone: string; days: CalendarDay[]
+ }[]
+}
+
+export function calendarTime(instant: string, timezone: string, locale: string) {
+ const date = new Date(instant)
+ const parts = new Intl.DateTimeFormat('en-CA', { timeZone: timezone,
+ year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(date)
+ const part = (type: string) => parts.find(value => value.type === type)?.value
+ return {
+ date: `${part('year')}-${part('month')}-${part('day')}`,
+ shortDate: `${part('month')}/${part('day')}`,
+ clock: new Intl.DateTimeFormat(locale, { timeZone: timezone, hour: '2-digit',
+ minute: '2-digit', hourCycle: 'h23' }).format(date),
+ }
+}
+
+const statusDot = (status: string) => status === 'trading' ? 'bg-emerald-500'
+ : ['pre_market', 'break'].includes(status) ? 'bg-amber-500' : 'bg-slate-400'
+const columns = 'grid grid-cols-[64px_repeat(3,minmax(0,1fr))] sm:grid-cols-[80px_repeat(3,minmax(0,1fr))]'
+
+export function MarketCalendarStatus({ markets, labelMarket }: {
+ markets: MarketStatus[]; labelMarket: (code: string) => string
+}) {
+ const { t } = useTranslation('configuration')
+ const [open, setOpen] = useState(false)
+ const [data, setData] = useState(null)
+ const [error, setError] = useState(false)
+ const [retry, setRetry] = useState(0)
+ const [loading, setLoading] = useState(false)
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
+ const calendarState = markets.map(market => `${market.code}:${market.local_date}:${market.status}`).join('|')
+ const locale = getCurrentLocale()
+
+ useEffect(() => {
+ if (!open) return
+ const controller = new AbortController()
+ setLoading(true)
+ setError(false)
+ fetchAPI(`/stocks/markets/calendar?days=14&timezone=${encodeURIComponent(timezone)}`, { signal: controller.signal })
+ .then(value => { if (!controller.signal.aborted) setData(value) })
+ .catch(() => { if (!controller.signal.aborted) setError(true) })
+ .finally(() => { if (!controller.signal.aborted) setLoading(false) })
+ return () => controller.abort()
+ }, [open, calendarState, timezone, retry])
+
+ const weekday = (date: string) => new Intl.DateTimeFormat(locale, {
+ weekday: 'short', timeZone: 'UTC',
+ }).format(new Date(`${date}T12:00:00Z`))
+ const sessionLabel = (instant: string, day: string) => {
+ const value = calendarTime(instant, timezone, locale)
+ const offset = Math.round((Date.parse(value.date) - Date.parse(day)) / 86400000)
+ if (offset === 0) return value.clock
+ if (offset === 1) return t('stocksPage.calendar.nextDay', { time: value.clock })
+ if (offset === -1) return t('stocksPage.calendar.previousDay', { time: value.clock })
+ return `${value.shortDate} ${value.clock}`
+ }
+ const timezoneLabel = timezone === 'Asia/Shanghai' ? t('stocksPage.calendar.beijing') : timezone
+
+ return (
+
+
+
+ {markets.map(market =>
+
+ {labelMarket(market.code)}
+ {t(`stocksPage.marketStatus.${market.status}`, { defaultValue: market.status_text })}
+ )}
+
+
+
+
+
+
+
+
{t('stocksPage.calendar.title')}
+
{t('stocksPage.calendar.displayTimezone', { timezone: timezoneLabel })}
+
+
setOpen(false)}>
+
+ {loading ? {t('stocksPage.calendar.loading')}
+ : error ? {t('stocksPage.calendar.error')}
setRetry(value => value + 1)}>{t('stocksPage.calendar.retry')}
+ : data && <>
+
+ {data.markets.map(market => {
+ const next = market.next_open ? calendarTime(market.next_open, timezone, locale) : null
+ const live = markets.find(value => value.code === market.market)
+ const status = live?.status ?? market.status
+ const localDate = live?.local_date ?? market.local_date
+ const localTime = live?.local_time ?? market.local_time
+ return
+
+ {labelMarket(market.market)}
+ {t(`stocksPage.marketStatus.${status}`, { defaultValue: status })}
+
+
{t('stocksPage.calendar.localDate', { date: localDate?.slice(5).replace('-', '/'), time: localTime })}
+
{t('stocksPage.calendar.nextOpen')}
+
+ {next ? <>{next.shortDate} {next.clock} > : {t('stocksPage.calendar.pending')} }
+
+
+ })}
+
+
+
+ {t('stocksPage.calendar.date')}
+ {data.markets.map(market => {labelMarket(market.market)} )}
+
+
+ {data.markets[0]?.days.map((day, index) =>
+
+
{day.date.slice(5).replace('-', '/')}
+
{weekday(day.date)}
+
+ {data.markets.map(market => {
+ const current = market.days[index]
+ return
+
{t(`stocksPage.calendar.reasons.${current.reason}`, { defaultValue: current.reason })}
+ {current.session_times.map(session =>
{sessionLabel(session.open, day.date)} – {sessionLabel(session.close, day.date)}
)}
+
+ })}
+
)}
+
+
+ >}
+
+
{t('stocksPage.calendar.statusRule')}
+
{t('stocksPage.calendar.source')}
+
+
+
+ )
+}
diff --git a/frontend/src/components/ShareCardDialog.tsx b/frontend/src/components/ShareCardDialog.tsx
index a3c8fb092..746f11e9f 100644
--- a/frontend/src/components/ShareCardDialog.tsx
+++ b/frontend/src/components/ShareCardDialog.tsx
@@ -1,3 +1,4 @@
+import { useToast } from '@panwatch/base-ui/components/ui/toast'
import { useRef, useState, type ReactNode } from 'react'
import { toPng } from 'html-to-image'
import { ImageDown, Loader2 } from 'lucide-react'
@@ -39,6 +40,7 @@ export default function ShareCardDialog({
children,
}: ShareCardDialogProps) {
const { t } = useTranslation('configuration')
+ const { toast } = useToast()
const shareT = t as unknown as (key: string, options?: Record) => string
const tr = (key: string, options?: Record) => shareT(`p5.share.${key}`, options)
const cardRef = useRef(null)
@@ -54,7 +56,7 @@ export default function ShareCardDialog({
link.href = dataUrl
link.click()
} catch (e) {
- alert(e instanceof Error ? tr('imageFailed', { message: e.message }) : tr('imageFailedRetry'))
+ toast(e instanceof Error ? tr('imageFailed', { message: e.message }) : tr('imageFailedRetry'), 'error')
} finally {
setBusy(false)
}
diff --git a/frontend/src/components/assistant/AgentPermissionsPanel.tsx b/frontend/src/components/assistant/AgentPermissionsPanel.tsx
index b3c444a6a..59bdcee6a 100644
--- a/frontend/src/components/assistant/AgentPermissionsPanel.tsx
+++ b/frontend/src/components/assistant/AgentPermissionsPanel.tsx
@@ -1,3 +1,4 @@
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@panwatch/base-ui/components/ui/select'
import type { AgentPermissions } from '@panwatch/api'
import { useTranslation } from 'react-i18next'
@@ -54,21 +55,14 @@ export function AgentPermissionsPanel({ permissions, onChange, variant = 'card'
className="flex min-w-0 items-center justify-between gap-1 rounded-xl border border-border/50 bg-accent/20 px-2.5 py-2 text-[11px] text-muted-foreground"
>
{riskLabels[item.risk]}
- onChange({
- selector_kind: 'risk',
- selector_value: item.risk,
- mode: event.target.value as PermissionMode,
- risk: item.risk,
- })}
- className="h-7 min-w-0 shrink-0 rounded-md border border-border/60 bg-background px-1.5 text-[11px] text-foreground outline-none focus:ring-1 focus:ring-primary/30"
- >
- {availableModesForRisk(item.risk).map((mode) => (
- {modeLabels[mode]}
- ))}
-
+ onChange({
+ selector_kind: 'risk', selector_value: item.risk, mode: value as PermissionMode, risk: item.risk,
+ })}>
+
+
+ {availableModesForRisk(item.risk).map(mode => {modeLabels[mode]} )}
+
+
))}
@@ -83,21 +77,14 @@ export function AgentPermissionsPanel({ permissions, onChange, variant = 'card'
{tool.name} · {riskLabels[tool.risk]}{tool.confirmation_required ? t('permissions.confirmationRequired') : ''}
- onChange({
- selector_kind: 'tool',
- selector_value: tool.name,
- mode: event.target.value as PermissionMode,
- risk: tool.risk,
- })}
- className="h-8 rounded-lg border border-border/60 bg-background px-2 text-[12px] text-foreground outline-none focus:ring-1 focus:ring-primary/30"
- >
- {availableModes(tool).map((mode) => (
- {modeLabels[mode]}
- ))}
-
+ onChange({
+ selector_kind: 'tool', selector_value: tool.name, mode: value as PermissionMode, risk: tool.risk,
+ })}>
+
+
+ {availableModes(tool).map(mode => {modeLabels[mode]} )}
+
+
))}
diff --git a/frontend/src/components/assistant/AssistantConfigPanel.tsx b/frontend/src/components/assistant/AssistantConfigPanel.tsx
index deee97822..3ba684160 100644
--- a/frontend/src/components/assistant/AssistantConfigPanel.tsx
+++ b/frontend/src/components/assistant/AssistantConfigPanel.tsx
@@ -1,3 +1,4 @@
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@panwatch/base-ui/components/ui/select'
import { useEffect, useState } from 'react'
import { Check, Cpu } from 'lucide-react'
import { chatApi, type AssistantConfig, type AssistantConfigUpdate } from '@panwatch/api'
@@ -116,19 +117,13 @@ export function AssistantConfigPanel() {
{tr('model')}
- update('compression_model_id', event.target.value)}
- className="h-9 w-full rounded-lg border border-border/60 bg-background px-2 text-[12px] text-foreground outline-none focus:ring-1 focus:ring-primary/30"
- >
- {tr('defaultModel')}
- {config.models.map((model) => (
-
- {model.service_name} / {model.name} ({model.model})
-
- ))}
-
+ update('compression_model_id', value === 'default' ? '' : value)}>
+
+
+ {tr('defaultModel')}
+ {config.models.map(model => {model.service_name} / {model.name} ({model.model}) )}
+
+
diff --git a/frontend/src/components/assistant/AssistantPermissionsDrawer.tsx b/frontend/src/components/assistant/AssistantPermissionsDrawer.tsx
index 1b56ac75d..061029a53 100644
--- a/frontend/src/components/assistant/AssistantPermissionsDrawer.tsx
+++ b/frontend/src/components/assistant/AssistantPermissionsDrawer.tsx
@@ -63,7 +63,7 @@ export function AssistantPermissionsDrawer({ open, onOpenChange }: AssistantPerm
{tr('description')}
-
+
{error &&
{error}
}
{permissions ? (
{ void changePermission(change) }} variant="drawer" />
diff --git a/frontend/src/components/assistant/AssistantResultCard.tsx b/frontend/src/components/assistant/AssistantResultCard.tsx
index 8ddd922d7..4f71616fd 100644
--- a/frontend/src/components/assistant/AssistantResultCard.tsx
+++ b/frontend/src/components/assistant/AssistantResultCard.tsx
@@ -1,3 +1,4 @@
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@panwatch/base-ui/components/ui/select'
import { useMemo, useState } from 'react'
import { Bell, ChevronDown, ExternalLink, FileSearch, ShieldAlert, Sparkles } from 'lucide-react'
import { useTranslation } from 'react-i18next'
@@ -215,10 +216,13 @@ export function AssistantResultCard({
{tr('configureAlert')}
-
setDirection(event.target.value as 'above' | 'below')} className="h-8 rounded-md border border-border bg-background px-2 text-[11px]">
- {tr('below')}
- {tr('above')}
-
+
setDirection(value as 'above' | 'below')}>
+
+
+ {tr('below')}
+ {tr('above')}
+
+
}
-
+
{error &&
{error}
}
{!loading && !error && query.trim() && results.length === 0 && (
{assistantT('assistantPage.stockPicker.empty')}
diff --git a/frontend/src/components/assistant/AssistantWelcome.tsx b/frontend/src/components/assistant/AssistantWelcome.tsx
index 6598d308e..c33d59a65 100644
--- a/frontend/src/components/assistant/AssistantWelcome.tsx
+++ b/frontend/src/components/assistant/AssistantWelcome.tsx
@@ -41,7 +41,7 @@ export function AssistantWelcome({ onSubmit, onSelectStock, disabled = false }:
}
return (
-
+
PANWATCH · {uiT('assistantPage.welcome.brandLabel')}
diff --git a/frontend/src/components/notifications/NotificationPanel.tsx b/frontend/src/components/notifications/NotificationPanel.tsx
index 0902edbf9..6d318f8b5 100644
--- a/frontend/src/components/notifications/NotificationPanel.tsx
+++ b/frontend/src/components/notifications/NotificationPanel.tsx
@@ -1,3 +1,4 @@
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@panwatch/base-ui/components/ui/select'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { notificationsApi, type NotificationFilter, type NotificationItem, type NotificationPage, type NotificationSource, type NotificationView } from '@panwatch/api'
@@ -58,12 +59,19 @@ export default function NotificationPanel({ monitor, onClose, onOpen }: Props) {
{tr('title')} {tr('description')}
{tr('summary', { unread: monitor.summary.unread_count, pending: monitor.summary.pending_action_count })}
- setSource(e.target.value as NotificationSource | '')} className={button}>
- {tr('sources.all')} {(['assistant', 'agent', 'market'] as const).map(value => {tr(`sources.${value}`)} )}
-
- setView(e.target.value as NotificationView)} className={button}>
- {(['all', 'unread', 'pending', 'archived'] as const).map(value => {tr(`views.${value}`)} )}
-
+ setSource(value === 'all' ? '' : value as NotificationSource)}>
+
+
+ {tr('sources.all')}
+ {(['assistant', 'agent', 'market'] as const).map(value => {tr(`sources.${value}`)} )}
+
+
+ setView(value as NotificationView)}>
+
+
+ {(['all', 'unread', 'pending', 'archived'] as const).map(value => {tr(`views.${value}`)} )}
+
+
{ void load(); void monitor.refresh() }}>{tr('refresh')}
void mutate()}>{tr('readFiltered')}
diff --git a/frontend/src/i18n/locales/en-US/configuration.ts b/frontend/src/i18n/locales/en-US/configuration.ts
index d446814ad..2779bc106 100644
--- a/frontend/src/i18n/locales/en-US/configuration.ts
+++ b/frontend/src/i18n/locales/en-US/configuration.ts
@@ -361,8 +361,8 @@ export const configuration = {
editor: { editTitle: 'Edit alert rule', createTitle: 'New alert rule', description: 'Supports price, change %, turnover, and volume-ratio conditions with AND / OR groups', submit: 'Save rule' },
hits: { title: 'Match history', empty: 'No matches yet', notifySuccess: 'Notification sent', notifyFailed: 'Notification failed {{error}}' },
conditions: { price: 'Price', change_pct: 'Change %', turnover: 'Turnover', volume: 'Volume', volume_ratio: 'Volume ratio', and: 'AND', or: 'OR' },
- testStatuses: { no_stock: 'Stock unavailable', no_quote: 'Quote unavailable', gated: 'Outside the active window', not_matched: 'Conditions not matched', would_trigger: 'Would trigger', duplicated: 'Duplicate suppressed', triggered: 'Triggered', unknown: 'Unknown' },
- form: { stock: 'Stock', selectStock: 'Select a stock', ruleName: 'Rule name', rulePlaceholder: 'For example Break above 120', conditionRelation: 'Condition relation', marketHours: 'Active hours', tradingOnly: 'Trading hours only', always: 'All day', cooldown: 'Cooldown (minutes)', dailyLimit: 'Daily limit', repeatMode: 'Trigger mode', repeat: 'Repeat', once: 'Once only', expireAt: 'Expiry (optional)', selectDate: 'Select date', clear: 'Clear', today: 'Today', neverExpire: 'Leave empty for no expiry', channels: 'Notification channels (empty = system default)', noChannels: 'No channels available', default: 'Default', conditions: 'Conditions', addCondition: 'Add condition', cancel: 'Cancel', saving: 'Saving...', weekdays: { sun: 'Sun', mon: 'Mon', tue: 'Tue', wed: 'Wed', thu: 'Thu', fri: 'Fri', sat: 'Sat' } },
+ testStatuses: { no_stock: 'Stock unavailable', no_quote: 'Quote unavailable', gated: 'Outside the active window', not_matched: 'Conditions not matched', would_trigger: 'Would trigger', duplicated: 'Duplicate suppressed', triggered: 'Triggered', unknown: 'Calendar pending' },
+ form: { stock: 'Stock', selectStock: 'Select a stock', ruleName: 'Rule name', rulePlaceholder: 'For example Break above 120', conditionRelation: 'Condition relation', marketHours: 'Active hours', tradingOnly: 'Trading hours only', always: 'All day on trading days', cooldown: 'Cooldown (minutes)', dailyLimit: 'Daily limit', repeatMode: 'Trigger mode', repeat: 'Repeat', once: 'Once only', expireAt: 'Expiry (optional)', selectDate: 'Select date', clear: 'Clear', today: 'Today', neverExpire: 'Leave empty for no expiry', channels: 'Notification channels (empty = system default)', noChannels: 'No channels available', default: 'Default', conditions: 'Conditions', addCondition: 'Add condition', cancel: 'Cancel', saving: 'Saving...', weekdays: { sun: 'Sun', mon: 'Mon', tue: 'Tue', wed: 'Wed', thu: 'Thu', fri: 'Fri', sat: 'Sat' } },
messages: { loadFailed: 'Load failed', selectStock: 'Select a stock', addCondition: 'Add at least one condition', saved: 'Rule saved', saveFailed: 'Save failed', toggleFailed: 'Failed to toggle rule', deleteConfirm: 'Delete rule “{{name}}”?', deleted: 'Deleted', deleteFailed: 'Delete failed', scanDone: 'Scan complete: {{triggered}} triggered, {{skipped}} skipped', scanFailed: 'Scan failed', testDone: 'Test complete: {{status}}', testFailed: 'Test failed', hitsFailed: 'Failed to load matches' },
},
dashboard: {
@@ -378,7 +378,8 @@ export const configuration = {
stocksPage: {
markets: { all: 'All', cn: 'Mainland China', hk: 'Hong Kong', us: 'US', hkShort: 'HK', usShort: 'US' },
quoteStatus: { closed: 'Market closed today; no daily change', pre_market: 'Market has not opened; no daily change', stale: 'Today’s quote has not updated', missing: 'Quote unavailable', unknown: 'Market status unknown', asOf: 'Quote date: {{date}}' },
- marketStatus: { trading: 'Open', pre_market: 'Pre-market', break: 'Midday break', after_hours: 'Closed', closed: 'Closed', unknown: 'Unknown' },
+ calendar: { close: 'Close trading calendar', localDate: 'Local {{date}} {{time}}', nextDay: 'Next day {{time}}', previousDay: 'Previous day {{time}}', statusRule: 'Status uses each market’s local date: closed on non-trading days, market closed after a trading session.', open: 'View all market calendars', title: 'Trading calendar · Next 14 days', nextOpen: 'Next open', displayTimezone: 'Times shown in {{timezone}}', beijing: 'Beijing time', date: 'Trade date', pending: 'Calendar pending', loading: 'Loading trading calendar…', error: 'Could not load trading calendar', retry: 'Retry', source: 'Published exchange calendar · Trade dates use each market’s local date', reasons: { trading: 'Trading day', weekend: 'Weekend', holiday: 'Exchange holiday', early_close: 'Early close', unpublished: 'Calendar pending' } },
+ marketStatus: { trading: 'Open', pre_market: 'Pre-market', break: 'Midday break', after_hours: 'Closed', closed: 'Closed', unknown: 'Calendar pending' },
messages: {
agent: 'Agent',
agentCount: '{{count}} Agents',
diff --git a/frontend/src/i18n/locales/zh-CN/configuration.ts b/frontend/src/i18n/locales/zh-CN/configuration.ts
index cdfcae43d..ade184f61 100644
--- a/frontend/src/i18n/locales/zh-CN/configuration.ts
+++ b/frontend/src/i18n/locales/zh-CN/configuration.ts
@@ -358,8 +358,8 @@ export const configuration = {
editor: { editTitle: '编辑提醒规则', createTitle: '新建提醒规则', description: '支持价格、涨跌幅、成交额、量比条件,支持 AND / OR 组合', submit: '保存规则' },
hits: { title: '命中历史', empty: '暂无命中记录', notifySuccess: '通知成功', notifyFailed: '通知失败 {{error}}' },
conditions: { price: '价格', change_pct: '涨跌幅%', turnover: '成交额', volume: '成交量', volume_ratio: '量比', and: '且', or: '或' },
- testStatuses: { no_stock: '股票不可用', no_quote: '行情不可用', gated: '不在生效时段', not_matched: '条件未命中', would_trigger: '将触发', duplicated: '重复触发已抑制', triggered: '已触发', unknown: '未知' },
- form: { stock: '股票', selectStock: '选择股票', ruleName: '规则名称', rulePlaceholder: '例如:突破120提醒', conditionRelation: '条件关系', marketHours: '生效时段', tradingOnly: '仅交易时段', always: '全天', cooldown: '冷却(分钟)', dailyLimit: '日上限', repeatMode: '触发模式', repeat: '可重复触发', once: '仅触发一次', expireAt: '到期时间(可选)', selectDate: '选择日期', clear: '清空', today: '今天', neverExpire: '留空表示永不过期', channels: '通知渠道(不选=系统默认)', noChannels: '暂无可用渠道', default: '默认', conditions: '条件列表', addCondition: '添加条件', cancel: '取消', saving: '保存中...', weekdays: { sun: '日', mon: '一', tue: '二', wed: '三', thu: '四', fri: '五', sat: '六' } },
+ testStatuses: { no_stock: '股票不可用', no_quote: '行情不可用', gated: '不在生效时段', not_matched: '条件未命中', would_trigger: '将触发', duplicated: '重复触发已抑制', triggered: '已触发', unknown: '日历待更新' },
+ form: { stock: '股票', selectStock: '选择股票', ruleName: '规则名称', rulePlaceholder: '例如:突破120提醒', conditionRelation: '条件关系', marketHours: '生效时段', tradingOnly: '仅交易时段', always: '交易日全天', cooldown: '冷却(分钟)', dailyLimit: '日上限', repeatMode: '触发模式', repeat: '可重复触发', once: '仅触发一次', expireAt: '到期时间(可选)', selectDate: '选择日期', clear: '清空', today: '今天', neverExpire: '留空表示永不过期', channels: '通知渠道(不选=系统默认)', noChannels: '暂无可用渠道', default: '默认', conditions: '条件列表', addCondition: '添加条件', cancel: '取消', saving: '保存中...', weekdays: { sun: '日', mon: '一', tue: '二', wed: '三', thu: '四', fri: '五', sat: '六' } },
messages: { loadFailed: '加载失败', selectStock: '请选择股票', addCondition: '至少添加一个条件', saved: '规则已保存', saveFailed: '保存失败', toggleFailed: '切换失败', deleteConfirm: '确认删除规则「{{name}}」?', deleted: '已删除', deleteFailed: '删除失败', scanDone: '扫描完成:触发 {{triggered}},跳过 {{skipped}}', scanFailed: '扫描失败', testDone: '测试完成:{{status}}', testFailed: '测试失败', hitsFailed: '加载命中失败' },
},
dashboard: {
@@ -375,7 +375,8 @@ export const configuration = {
stocksPage: {
markets: { all: '全部', cn: 'A股', hk: '港股', us: '美股', hkShort: '港', usShort: '美' },
quoteStatus: { closed: '今日休市,暂无今日涨跌', pre_market: '尚未开盘,暂无今日涨跌', stale: '今日行情尚未更新', missing: '暂无行情', unknown: '市场状态未知', asOf: '行情日期:{{date}}' },
- marketStatus: { trading: '交易中', pre_market: '盘前', break: '午间休市', after_hours: '已收盘', closed: '休市', unknown: '未知' },
+ calendar: { close: '关闭交易日历', localDate: '当地 {{date}} {{time}}', nextDay: '次日 {{time}}', previousDay: '前日 {{time}}', statusRule: '状态按市场当地日期判断:非交易日为休市,当日交易结束后为已收盘。', open: '查看各市场交易日历', title: '交易日历 · 未来14天', nextOpen: '下次开盘', displayTimezone: '时间已换算为{{timezone}}', beijing: '北京时间', date: '交易日期', pending: '日历待更新', loading: '正在读取交易日历…', error: '交易日历读取失败', retry: '重试', source: '交易所公布日历 · 交易日期按各市场当地日期', reasons: { trading: '交易日', weekend: '周末休市', holiday: '交易所休市', early_close: '半日市', unpublished: '日历待更新' } },
+ marketStatus: { trading: '交易中', pre_market: '盘前', break: '午间休市', after_hours: '已收盘', closed: '休市', unknown: '日历待更新' },
messages: {
agent: 'Agent',
agentCount: '{{count}} 个 Agent',
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 641360a71..674c89e57 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -4,6 +4,7 @@
@layer base {
:root {
+ color-scheme: light;
--background: 220 20% 97%;
--foreground: 220 20% 10%;
--card: 0 0% 100%;
@@ -34,6 +35,7 @@
}
.dark {
+ color-scheme: dark;
--background: 224 20% 6%;
--foreground: 220 15% 90%;
--card: 224 20% 10%;
diff --git a/frontend/src/lib/portfolio-quotes.ts b/frontend/src/lib/portfolio-quotes.ts
index 8d2042529..1bfbdac65 100644
--- a/frontend/src/lib/portfolio-quotes.ts
+++ b/frontend/src/lib/portfolio-quotes.ts
@@ -195,3 +195,19 @@ export const mergePortfolioQuotes = (
}
}
+
+export const applyMarketStatuses = (
+ quotes: Record,
+ markets: { code: string; status: string; local_date?: string }[],
+): Record => {
+ const statuses = new Map(markets.map(market => [market.code, market]))
+ return Object.fromEntries(Object.entries(quotes).map(([key, quote]) => {
+ const market = statuses.get(key.split(':')[0])
+ if (!market) return [key, quote]
+ const inactive = ['closed', 'pre_market', 'unknown'].includes(market.status)
+ const stale = quote.quote_date && market.local_date && quote.quote_date !== market.local_date
+ return [key, inactive || stale ? {
+ ...quote, change_pct: null, daily_move_status: inactive ? market.status : 'stale',
+ } : quote]
+ }))
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 600963512..75aaf97a0 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -1,4 +1,6 @@
import React from 'react'
+import { useTranslation } from 'react-i18next'
+import { ConfirmProvider } from '@panwatch/base-ui/components/ui/confirm-dialog'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
@@ -10,12 +12,17 @@ import './index.css'
applyMarketColorScheme(readMarketColorPreference(), getCurrentLocale())
+function ApplicationConfirmProvider({ children }: { children: React.ReactNode }) {
+ const { t } = useTranslation('common')
+ return {children}
+}
+
ReactDOM.createRoot(document.getElementById('root')!).render(
-
+
diff --git a/frontend/src/pages/Agents.tsx b/frontend/src/pages/Agents.tsx
index 8fba04f69..ad498eed8 100644
--- a/frontend/src/pages/Agents.tsx
+++ b/frontend/src/pages/Agents.tsx
@@ -265,7 +265,7 @@ export default function AgentsPage() {
const timer = setTimeout(async () => {
setSchedulePreviewLoading(true)
try {
- const p = await fetchAPI(`/agents/schedule/preview?schedule=${encodeURIComponent(cron)}&count=5`)
+ const p = await fetchAPI(`/agents/schedule/preview?schedule=${encodeURIComponent(cron)}&agent_name=${encodeURIComponent(scheduleDialogAgent.name)}&count=5`)
setSchedulePreview(p)
} catch (e) {
const msg = e instanceof Error ? e.message : configT('messages.previewFailed')
@@ -902,7 +902,7 @@ export default function AgentsPage() {
-
+
{filteredBindStocks.length === 0 ? (
{configT('binding.empty')}
) : (
@@ -1047,15 +1047,14 @@ export default function AgentsPage() {
{configT('advanced.overBudget')}
- setTaConfigForm({ ...taConfigForm, over_budget_action: e.target.value })}
- >
- {configT('advanced.reject')}
- {configT('advanced.warn')}
- {configT('advanced.continue')}
-
+ setTaConfigForm({ ...taConfigForm, over_budget_action: value })}>
+
+
+ {configT('advanced.reject')}
+ {configT('advanced.warn')}
+ {configT('advanced.continue')}
+
+
{configT('advanced.debateRounds')}
diff --git a/frontend/src/pages/AnalysisDetail.tsx b/frontend/src/pages/AnalysisDetail.tsx
index 5bd95d813..11c631579 100644
--- a/frontend/src/pages/AnalysisDetail.tsx
+++ b/frontend/src/pages/AnalysisDetail.tsx
@@ -1,3 +1,4 @@
+import { useToast } from '@panwatch/base-ui/components/ui/toast'
import { useEffect, useState, type ReactNode } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import ReactMarkdown from 'react-markdown'
@@ -106,6 +107,7 @@ function parseHeadings(markdown: string): { text: string; slug: string }[] {
export default function AnalysisDetailPage() {
const { t, i18n } = useTranslation('configuration')
+ const { toast } = useToast()
const analysisT = t as unknown as (key: string, options?: Record) => string
const { symbol = '', date = '' } = useParams()
const navigate = useNavigate()
@@ -130,7 +132,7 @@ export default function AnalysisDetailPage() {
try {
await tradingAgentsApi.downloadAnalysisPdf(symbol, date)
} catch (e) {
- alert(e instanceof Error ? e.message : analysisT('assistantPage.analysis.exportFailed'))
+ toast(e instanceof Error ? e.message : analysisT('assistantPage.analysis.exportFailed'), 'error')
} finally {
setPdfBusy(false)
}
@@ -401,7 +403,7 @@ export default function AnalysisDetailPage() {
)}
{items.length > 0 ? (
-
+
diff --git a/frontend/src/pages/DataSources.tsx b/frontend/src/pages/DataSources.tsx
index 7c6131bdf..c3f4b25e6 100644
--- a/frontend/src/pages/DataSources.tsx
+++ b/frontend/src/pages/DataSources.tsx
@@ -1,3 +1,4 @@
+import { useConfirm } from '@panwatch/base-ui/components/ui/confirm-dialog'
import { useState, useEffect } from 'react'
import { Pencil, Play, Database, Newspaper, LineChart, TrendingUp, DollarSign, Image, Layers, Zap, Check, X, Clock, Trash2, ChevronUp, ChevronDown, ChevronRight, Eye, EyeOff, RotateCcw, AlertTriangle, BarChart3, Trophy, Landmark, Users, Gift, ArrowLeftRight } from 'lucide-react'
import { fetchAPI, resetDataSourcesToSeed, type DataSource } from '@panwatch/api'
@@ -128,6 +129,7 @@ const emptyForm: DataSourceForm = {
export default function DataSourcesPage() {
const { t } = useTranslation(['configuration', 'common'])
+ const confirmAction = useConfirm()
const configT = t as unknown as (key: string, options?: Record) => string
const [sources, setSources] = useState([])
const [loading, setLoading] = useState(true)
@@ -248,7 +250,7 @@ export default function DataSourcesPage() {
}
const resetToSeed = async () => {
- if (!window.confirm(t('configuration:dataSources.messages.resetConfirm'))) return
+ if (!(await confirmAction(t('configuration:dataSources.messages.resetConfirm'), { destructive: true }))) return
setResetting(true)
try {
const result = await resetDataSourcesToSeed()
@@ -263,7 +265,7 @@ export default function DataSourcesPage() {
const deleteSource = async () => {
if (!editId) return
- if (!window.confirm(t('configuration:dataSources.messages.deleteConfirm', { name: form.name }))) return
+ if (!(await confirmAction(t('configuration:dataSources.messages.deleteConfirm', { name: form.name }), { destructive: true }))) return
try {
await fetchAPI(`/datasources/${editId}`, { method: 'DELETE' })
setDialogOpen(false); load(); toast(t('configuration:dataSources.messages.deleted'), 'success')
@@ -567,7 +569,7 @@ export default function DataSourcesPage() {
{t('configuration:dataSources.result.logs')}
-
+
{testResult.logs.map((log, i) => (
{log.timestamp}
@@ -603,7 +605,7 @@ export default function DataSourcesPage() {
{testResult?.test_passed && testResult.items && testResult.source_type !== 'chart' && Array.isArray(testResult.items) && testResult.items.length > 0 && (
{t('configuration:dataSources.result.preview')}
-
+
{/* News type */}
{testResult.source_type === 'news' && testResult.items.map((item, i) => {
diff --git a/frontend/src/pages/Evaluations.tsx b/frontend/src/pages/Evaluations.tsx
index c20292189..2b09d53c1 100644
--- a/frontend/src/pages/Evaluations.tsx
+++ b/frontend/src/pages/Evaluations.tsx
@@ -187,7 +187,7 @@ export default function EvaluationsPage() {
-
{ev('details')}
{ev('suggestionCount', { count: data?.total ?? 0 })}
{loading ? {ev('loadingReview')}
: rows.length === 0 ? {ev('noRows')}
: {ev('suggestionDate')} {ev('symbol')} {ev('source')} {ev('action')} {ev('confidence')} {ev('suggestedPrice')} {ev('tradingDays', { count: 1 })} {ev('tradingDays', { count: 5 })} {rows.map(row => setSelected(row)} className="border-b border-border/40 cursor-pointer hover:bg-accent/30 transition-colors">{row.prediction_date} {row.stock_symbol}{row.stock_market} {row.agent_name} {row.action_label || (ACTION_KEYS[row.action] ? ev(`actions.${ACTION_KEYS[row.action]}`) : row.action)} {row.is_legacy_group && {ev('legacyBasis')} }{row.confidence == null ? '--' : row.confidence.toFixed(2)} {row.trigger_price == null ? '--' : row.trigger_price.toFixed(2)} ev(key)} /> ev(key)} /> )}
}
+
{ev('details')}
{ev('suggestionCount', { count: data?.total ?? 0 })}
{loading ? {ev('loadingReview')}
: rows.length === 0 ? {ev('noRows')}
: {ev('suggestionDate')} {ev('symbol')} {ev('source')} {ev('action')} {ev('confidence')} {ev('suggestedPrice')} {ev('tradingDays', { count: 1 })} {ev('tradingDays', { count: 5 })} {rows.map(row => setSelected(row)} className="border-b border-border/40 cursor-pointer hover:bg-accent/30 transition-colors">{row.prediction_date} {row.stock_symbol}{row.stock_market} {row.agent_name} {row.action_label || (ACTION_KEYS[row.action] ? ev(`actions.${ACTION_KEYS[row.action]}`) : row.action)} {row.is_legacy_group && {ev('legacyBasis')} }{row.confidence == null ? '--' : row.confidence.toFixed(2)} {row.trigger_price == null ? '--' : row.trigger_price.toFixed(2)} ev(key)} /> ev(key)} /> )}
}
!open && setSelected(null)}>{selected ? `${selected.stock_symbol} · ${selected.action_label || (ACTION_KEYS[selected.action] ? ev(`actions.${ACTION_KEYS[selected.action]}`) : selected.action)}` : ev('detailTitle')} {selected?.prediction_date} · {selected?.agent_name} · {selected?.stock_market} {selected && {ev('confidence')}
{selected.confidence == null ? '--' : selected.confidence.toFixed(2)}
{ev('price')}
{selected.trigger_price == null ? '--' : selected.trigger_price.toFixed(2)}
{ev('evaluationBasis')}
{selected.is_legacy_group ? ev('oldCalendarDays') : ev('tradingDaysShort')}
{(selected.reason || selected.signal) &&
{ev('rationale')}
{selected.signal &&
{ev('signal')}: {selected.signal}
}{selected.reason &&
{selected.reason}
}
}
{ev('outcome')}
{['1', '5'].map(horizon => { const outcome = selected.outcomes[horizon]; return
{ev('tradingDays', { count: Number(horizon) })}
{outcome?.status === 'pending' ? ev('outcomes.pending') : formatPct(outcome?.return_pct)}
{outcomeLabel(outcome, key => ev(key))}
})}
{policy &&
{ev('matchedRules')}
{policy.actions[selected.action] || ev('flatSuggestionRule', { threshold: policy.flat_threshold_pct })}
}
}
}
diff --git a/frontend/src/pages/History.tsx b/frontend/src/pages/History.tsx
index 0248677f3..a205cf976 100644
--- a/frontend/src/pages/History.tsx
+++ b/frontend/src/pages/History.tsx
@@ -1,3 +1,4 @@
+import { useConfirm } from '@panwatch/base-ui/components/ui/confirm-dialog'
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Clock, Trash2, FileText, ArrowLeft } from 'lucide-react'
@@ -32,6 +33,7 @@ const CAPABILITY_AGENT_KEYS = ['news_digest', 'chart_analyst']
export default function HistoryPage() {
const { toast } = useToast()
const { t } = useTranslation('configuration')
+ const confirmAction = useConfirm()
const historyT = t as unknown as (key: string, options?: Record
) => string
const tr = (key: string, options?: Record) => historyT(`p4.history.${key}`, options)
const agentLabel = (key: string) => {
@@ -120,7 +122,7 @@ export default function HistoryPage() {
}, [records, selectedId])
const deleteRecord = async (id: number) => {
- if (!confirm(tr('deleteConfirm'))) return
+ if (!(await confirmAction(tr('deleteConfirm'), { destructive: true }))) return
try {
await fetchAPI(`/history/${id}`, { method: 'DELETE' })
toast(tr('deleted'), 'success')
@@ -338,25 +340,25 @@ export default function HistoryPage() {
{detailRecord?.prompt_stats ? (
{tr('promptStats')}
-
{JSON.stringify(detailRecord.prompt_stats, null, 2)}
+
{JSON.stringify(detailRecord.prompt_stats, null, 2)}
) : null}
{detailRecord?.context_payload ? (
{tr('contextSnapshot')}
-
{JSON.stringify(detailRecord.context_payload, null, 2)}
+
{JSON.stringify(detailRecord.context_payload, null, 2)}
) : null}
{detailRecord?.news_debug ? (
{tr('newsDetails')}
-
{JSON.stringify(detailRecord.news_debug, null, 2)}
+
{JSON.stringify(detailRecord.news_debug, null, 2)}
) : null}
{detailRecord?.prompt_context ? (
{tr('promptOriginal')}
-
{detailRecord.prompt_context}
+
{detailRecord.prompt_context}
) : null}
diff --git a/frontend/src/pages/PaperTrading.tsx b/frontend/src/pages/PaperTrading.tsx
index 412ce159f..9a3a3c8a5 100644
--- a/frontend/src/pages/PaperTrading.tsx
+++ b/frontend/src/pages/PaperTrading.tsx
@@ -1,3 +1,4 @@
+import { useConfirm } from '@panwatch/base-ui/components/ui/confirm-dialog'
import { useEffect, useState, useCallback } from 'react'
import { RefreshCw, Power, RotateCcw, X, TrendingUp, TrendingDown, Trophy, BarChart3, Wallet, Activity, Play, Bell, SlidersHorizontal } from 'lucide-react'
import {
@@ -107,6 +108,7 @@ function EquityChart({ data }: { data: EquityCurvePoint[] }) {
export default function PaperTradingPage() {
const { toast } = useToast()
const { t, i18n } = useTranslation('configuration')
+ const confirmAction = useConfirm()
const paperT = t as unknown as (key: string, options?: Record) => string
const locale = i18n.resolvedLanguage === 'en-US' ? 'en-US' : 'zh-CN'
const tr = (key: string, options?: Record) => paperT(`p4.paperTrading.${key}`, options)
@@ -181,7 +183,7 @@ export default function PaperTradingPage() {
}
const handleReset = async () => {
- if (!confirm(message('resetConfirm'))) return
+ if (!(await confirmAction(message('resetConfirm'), { destructive: true }))) return
try {
await paperTradingApi.resetAccount()
toast(message('resetDone'), 'success')
@@ -459,7 +461,7 @@ export default function PaperTradingPage() {
{strategyPerf.length > 0 && (
{tr('strategyPerformance')}
-
+
@@ -506,7 +508,7 @@ export default function PaperTradingPage() {
{positions.length === 0 ? (
{tr('noPositions')}
) : (
-
+
@@ -568,7 +570,7 @@ export default function PaperTradingPage() {
{tr('noTrades')}
) : (
<>
-
+
diff --git a/frontend/src/pages/PriceAlerts.tsx b/frontend/src/pages/PriceAlerts.tsx
index 0ab89d1fd..27c709393 100644
--- a/frontend/src/pages/PriceAlerts.tsx
+++ b/frontend/src/pages/PriceAlerts.tsx
@@ -1,3 +1,4 @@
+import { useConfirm } from '@panwatch/base-ui/components/ui/confirm-dialog'
import { useEffect, useMemo, useState } from 'react'
import { useLocation } from 'react-router-dom'
import { Plus, RefreshCw, Play, Trash2, BarChart3, BellRing } from 'lucide-react'
@@ -81,6 +82,7 @@ function conditionText(item: AlertConditionItem, translate: (key: string) => str
export default function PriceAlertsPage() {
const { t } = useTranslation('configuration')
+ const confirmAction = useConfirm()
const alertT = (key: string, options?: Record) =>
(t as unknown as (translationKey: string, interpolation?: Record) => string)(
`priceAlerts.${key}`,
@@ -234,7 +236,7 @@ export default function PriceAlertsPage() {
}
const removeRule = async (r: AlertRule) => {
- if (!window.confirm(alertT('messages.deleteConfirm', { name: r.name || r.stock_name }))) return
+ if (!(await confirmAction(alertT('messages.deleteConfirm', { name: r.name || r.stock_name }), { destructive: true }))) return
try {
await fetchAPI(`/price-alerts/${r.id}`, { method: 'DELETE' })
await load()
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx
index b8f22afab..95b4efc69 100644
--- a/frontend/src/pages/Settings.tsx
+++ b/frontend/src/pages/Settings.tsx
@@ -1,3 +1,4 @@
+import { useConfirm } from '@panwatch/base-ui/components/ui/confirm-dialog'
import { useState, useEffect, useRef } from 'react'
import { Check, Eye, EyeOff, Plus, Pencil, Trash2, Star, Send, Cpu, Play, Download, Upload, BarChart3, Radar, AlertTriangle, Palette } from 'lucide-react'
import { fetchAPI, type AIService, type AIModel, type NotifyChannel } from '@panwatch/api'
@@ -189,6 +190,7 @@ const emptyChannelForm: ChannelForm = { name: '', type: 'telegram', config: {} }
export default function SettingsPage() {
const { t } = useTranslation(['configuration', 'common'])
+ const confirmAction = useConfirm()
const configT = t as unknown as (key: string, options?: Record) => string
const { preference: marketColorPreference, setPreference: setMarketColorPreference } = useMarketColors()
const [settings, setSettings] = useState([])
@@ -517,7 +519,7 @@ export default function SettingsPage() {
}
const deleteService = async (id: number) => {
- if (!confirm(configT('configuration:settingsPage.messages.deleteServiceConfirm'))) return
+ if (!(await confirmAction(configT('configuration:settingsPage.messages.deleteServiceConfirm'), { destructive: true }))) return
try {
await fetchAPI(`/providers/services/${id}`, { method: 'DELETE' })
load()
@@ -553,7 +555,7 @@ export default function SettingsPage() {
}
const deleteModel = async (id: number) => {
- if (!confirm(configT('configuration:settingsPage.messages.deleteModelConfirm'))) return
+ if (!(await confirmAction(configT('configuration:settingsPage.messages.deleteModelConfirm'), { destructive: true }))) return
try {
await fetchAPI(`/providers/models/${id}`, { method: 'DELETE' })
load()
@@ -629,7 +631,7 @@ export default function SettingsPage() {
}
const deleteChannel = async (id: number) => {
- if (!confirm(configT('configuration:settingsPage.messages.deleteChannelConfirm'))) return
+ if (!(await confirmAction(configT('configuration:settingsPage.messages.deleteChannelConfirm'), { destructive: true }))) return
try {
await fetchAPI(`/channels/${id}`, { method: 'DELETE' })
load()
diff --git a/frontend/src/pages/Stocks.tsx b/frontend/src/pages/Stocks.tsx
index 57e4b50ed..6a9b04bd7 100644
--- a/frontend/src/pages/Stocks.tsx
+++ b/frontend/src/pages/Stocks.tsx
@@ -1,3 +1,4 @@
+import { useConfirm } from '@panwatch/base-ui/components/ui/confirm-dialog'
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Plus, Trash2, Pencil, Search, X, TrendingUp, Bot, Play, RefreshCw, Wallet, PiggyBank, ArrowUpRight, ArrowDownRight, Building2, ChevronDown, ChevronRight, Cpu, Bell, Clock, Newspaper, ExternalLink, BarChart3, Brain } from 'lucide-react'
@@ -28,7 +29,8 @@ import StockPriceAlertPanel from '@panwatch/biz-ui/components/stock-price-alert-
import { useTranslation } from 'react-i18next'
import { localizeAgentDescription, localizeAgentName } from '@/i18n/agent-labels'
import { getCurrentLocale } from '@/i18n'
-import { mergePortfolioQuotes, toQuoteMap, type Position, type PortfolioSummary, type QuoteResponse, type DisplayQuote } from '@/lib/portfolio-quotes'
+import { applyMarketStatuses, mergePortfolioQuotes, toQuoteMap, type Position, type PortfolioSummary, type QuoteResponse, type DisplayQuote } from '@/lib/portfolio-quotes'
+import { MarketCalendarStatus, type MarketStatus } from '@/components/MarketCalendarStatus'
import { marketSignTextClass } from '@/lib/market-colors'
import { parseAssistantPortfolioTarget } from '@/lib/assistant-navigation'
@@ -146,16 +148,6 @@ interface PoolSuggestion {
should_alert?: boolean
}
-interface MarketStatus {
- code: string
- name: string
- status: string
- status_text: string
- is_trading: boolean
- sessions: string[]
- local_time: string
-}
-
interface NewsItem {
source: string
source_label: string
@@ -210,6 +202,7 @@ const toPriceAlertSummaryMap = (rows: PriceAlertRuleSummary[]): Record) => string
const klineT = (key: string, options?: Record) =>
stockT(`bizUi:kline.${key}`, options)
@@ -273,6 +266,9 @@ export default function StocksPage() {
// Market status
const [marketStatus, setMarketStatus] = useState([])
+ const marketStatusRef = useRef([])
+ const previousMarketStatusRef = useRef([])
+ marketStatusRef.current = marketStatus
// Guard to prevent overlapping K线刷新任务导致实际并发超限
const klineRefreshInFlight = useRef | null>(null)
const initialLoadPromiseRef = useRef | null>(null)
@@ -510,15 +506,18 @@ export default function StocksPage() {
}
}, [refreshMarketStatus])
- const refreshQuotes = useCallback(async () => {
- const items = buildQuoteItems()
+ const refreshQuotes = useCallback(async (automatic = false) => {
+ const statuses = marketStatusRef.current
+ const items = buildQuoteItems().filter(item => !automatic ||
+ !statuses.some(status => status.code === item.market) ||
+ statuses.some(status => status.code === item.market && status.is_trading))
if (items.length === 0) return
setQuotesLoading(true)
try {
const data = await requestQuotes(items)
if (data.length > 0) {
- setQuotes(toQuoteMap(data))
+ setQuotes(previous => ({ ...previous, ...toQuoteMap(data) }))
setLastRefreshTime(new Date())
}
} finally {
@@ -528,8 +527,8 @@ export default function StocksPage() {
useEffect(() => {
if (!portfolioRaw) return
- setPortfolio(mergePortfolioQuotes(portfolioRaw, quotes))
- }, [portfolioRaw, quotes])
+ setPortfolio(mergePortfolioQuotes(portfolioRaw, applyMarketStatuses(quotes, marketStatus)))
+ }, [portfolioRaw, quotes, marketStatus])
// 刷新 K 线摘要(批量接口);并防止重入
const refreshKlines = useCallback(async () => {
@@ -545,6 +544,14 @@ export default function StocksPage() {
try { await run } finally { klineRefreshInFlight.current = null }
}, [buildQuoteItems, requestKlineSummaries])
+ // Fetch the closing snapshot once, then stop polling closed-market quotes.
+ useEffect(() => {
+ const previous = previousMarketStatusRef.current
+ previousMarketStatusRef.current = marketStatus
+ if (autoRefresh && marketStatus.some(market => market.status === 'after_hours' &&
+ previous.some(old => old.code === market.code && old.is_trading))) void refreshQuotes()
+ }, [marketStatus, autoRefresh, refreshQuotes])
+
// 从建议池加载建议(包含历史建议和多来源建议)
const loadPoolSuggestions = useCallback(async (itemsOverride?: QuoteRequestItem[]) => {
setPoolSuggestionsLoading(true)
@@ -802,16 +809,16 @@ export default function StocksPage() {
if (!agents || agents.length === 0) return
const stockAgentMap = new Map((agentDialogStock.agents || []).map(a => [a.agent_name, a]))
- const schedules = new Set()
+ const schedules = new Map()
for (const agent of agents) {
if (agent.execution_mode === 'batch') continue
const sa = stockAgentMap.get(agent.name)
if (!sa) continue
const eff = effectiveSchedule(agent, sa)
- if (eff) schedules.add(eff)
+ if (eff) schedules.set(`${agent.name}|${agentDialogStock?.market}|${eff}`, { schedule: eff, agent: agent.name })
}
- const toFetch = Array.from(schedules).filter(s => !schedulePreviewCache[s] && !schedulePreviewLoading[s])
+ const toFetch = Array.from(schedules.keys()).filter(s => !schedulePreviewCache[s] && !schedulePreviewLoading[s])
if (toFetch.length === 0) return
let cancelled = false
@@ -825,7 +832,8 @@ export default function StocksPage() {
try {
const pairs = await Promise.all(toFetch.map(async s => {
try {
- const p = await fetchAPI(`/agents/schedule/preview?schedule=${encodeURIComponent(s)}&count=5`)
+ const plan = schedules.get(s)!
+ const p = await fetchAPI(`/agents/schedule/preview?schedule=${encodeURIComponent(plan.schedule)}&agent_name=${encodeURIComponent(plan.agent)}&market=${agentDialogStock.market}&count=5`)
return [s, p] as const
} catch (e) {
const msg = e instanceof Error ? e.message : stockT('stocksPage.messages.previewFailed')
@@ -867,9 +875,9 @@ export default function StocksPage() {
// Auto-refresh timer
useEffect(() => {
if (autoRefresh) {
- refreshQuotes()
+ refreshQuotes(true)
refreshTimerRef.current = setInterval(() => {
- refreshQuotes()
+ refreshQuotes(true)
}, refreshInterval * 1000)
} else {
// Clear interval when disabled
@@ -1021,7 +1029,7 @@ export default function StocksPage() {
}
const handleDeleteAccount = async (id: number) => {
- if (!confirm(stockT('stocksPage.messages.deleteAccountConfirm'))) return
+ if (!(await confirmAction(stockT('stocksPage.messages.deleteAccountConfirm'), { destructive: true }))) return
try {
await fetchAPI(`/accounts/${id}`, { method: 'DELETE' })
load()
@@ -1167,7 +1175,7 @@ export default function StocksPage() {
}
const handleDeletePosition = async (id: number) => {
- if (!confirm(stockT('stocksPage.messages.deletePositionConfirm'))) return
+ if (!(await confirmAction(stockT('stocksPage.messages.deletePositionConfirm'), { destructive: true }))) return
try {
await fetchAPI(`/positions/${id}`, { method: 'DELETE' })
loadPortfolio()
@@ -1290,8 +1298,6 @@ export default function StocksPage() {
const date = quote.quote_date ? stockT('stocksPage.quoteStatus.asOf', { date: quote.quote_date }) : ''
return [label, date].filter(Boolean).join(' · ')
}
- const marketStatusLabel = (status: string, fallback: string) =>
- stockT(`stocksPage.marketStatus.${status}`, { defaultValue: fallback })
// 市场徽章样式和短标签
const marketBadge = (m: string) => {
@@ -1530,29 +1536,7 @@ export default function StocksPage() {
{/* 移动端 row 2:市场状态 + 自动刷新 + 时间戳合并到同一行,横向滚动避免换行;桌面端只展示市场 pills (auto-refresh 在桌面顶部已展示) */}
- {marketStatus.map(m => {
- const statusColors: Record
= {
- trading: 'bg-emerald-500',
- pre_market: 'bg-amber-500',
- break: 'bg-amber-500',
- after_hours: 'bg-slate-400',
- closed: 'bg-slate-400',
- }
- const localizedStatus = marketStatusLabel(m.status, m.status_text)
- return (
-
-
- {marketLabel(m.code)}
-
- {localizedStatus}
-
-
- )
- })}
+ {marketStatus.length > 0 && }
{/* 移动端紧凑型自动刷新控件 */}
@@ -1879,7 +1863,7 @@ export default function StocksPage() {
) : (
<>
{/* Desktop Table */}
-
+
@@ -2805,8 +2789,9 @@ export default function StocksPage() {
{(() => {
const eff = effectiveSchedule(agent, stockAgent)
const isFollowingGlobal = !(stockAgent?.schedule || '').trim() && !!(agent.schedule || '').trim()
- const preview = eff ? schedulePreviewCache[eff] : null
- const isLoading = eff ? !!schedulePreviewLoading[eff] : false
+ const previewKey = `${agent.name}|${agentDialogStock?.market}|${eff}`
+ const preview = eff ? schedulePreviewCache[previewKey] : null
+ const isLoading = eff ? !!schedulePreviewLoading[previewKey] : false
if (!eff) return null
return (
@@ -2991,7 +2976,7 @@ export default function StocksPage() {
{/* 新闻列表 */}
-
+
{newsLoading ? (
diff --git a/frontend/tests/assistant/AssistantConfigPanel.test.tsx b/frontend/tests/assistant/AssistantConfigPanel.test.tsx
index a5b8030e0..6df9eb4f7 100644
--- a/frontend/tests/assistant/AssistantConfigPanel.test.tsx
+++ b/frontend/tests/assistant/AssistantConfigPanel.test.tsx
@@ -28,7 +28,7 @@ describe('AssistantConfigPanel', () => {
render(
)
- expect((await screen.findByLabelText('上下文压缩模型') as HTMLSelectElement).value).toBe('6')
+ expect((await screen.findByRole('combobox', { name: '上下文压缩模型' })).textContent).toContain('DeepSeek V4 Flash')
fireEvent.change(screen.getByLabelText('最大上下文 Token'), { target: { value: '16000' } })
fireEvent.click(screen.getByRole('button', { name: '保存上下文配置' }))
diff --git a/frontend/tests/assistant/AssistantPermissionsDrawer.test.tsx b/frontend/tests/assistant/AssistantPermissionsDrawer.test.tsx
index e676044b6..8c2f9a492 100644
--- a/frontend/tests/assistant/AssistantPermissionsDrawer.test.tsx
+++ b/frontend/tests/assistant/AssistantPermissionsDrawer.test.tsx
@@ -1,4 +1,5 @@
-import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
const { getAgentPermissions, updateAgentPermission, getAssistantConfig } = vi.hoisted(() => ({
@@ -45,7 +46,8 @@ describe('AssistantPermissionsDrawer', () => {
render(
)
await screen.findByLabelText('查询持仓')
- fireEvent.change(screen.getByLabelText('查询持仓'), { target: { value: 'ask' } })
+ await userEvent.click(screen.getByRole('combobox', { name: '查询持仓' }))
+ await userEvent.click(await screen.findByRole('option', { name: '每次询问' }))
await waitFor(() => expect(updateAgentPermission).toHaveBeenCalledWith({
selector_kind: 'tool',
diff --git a/frontend/tests/notifications/NotificationProvider.test.tsx b/frontend/tests/notifications/NotificationProvider.test.tsx
index 495f45b77..658cef948 100644
--- a/frontend/tests/notifications/NotificationProvider.test.tsx
+++ b/frontend/tests/notifications/NotificationProvider.test.tsx
@@ -1,3 +1,4 @@
+import userEvent from '@testing-library/user-event'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { MemoryRouter, useLocation } from 'react-router-dom'
@@ -175,12 +176,15 @@ describe('global notifications', () => {
await screen.findByText('日报')
fireEvent.click(screen.getByRole('button', { name: '加载更多' }))
expect(await screen.findByText('价格规则')).toBeTruthy()
- fireEvent.change(screen.getByRole('combobox', { name: '通知来源' }), { target: { value: 'agent' } })
+ await userEvent.click(screen.getByRole('combobox', { name: '通知来源' }))
+ await userEvent.click(await screen.findByRole('option', { name: 'Agent 报告' }))
await waitFor(() => expect(notificationsApi.list).toHaveBeenLastCalledWith(expect.objectContaining({ source: 'agent' }), expect.any(AbortSignal)))
fireEvent.click(screen.getByRole('button', { name: '当前筛选全部已读' }))
await waitFor(() => expect(notificationsApi.read).toHaveBeenCalledWith({ source: 'agent', view: 'all', through_id: 1012 }))
- fireEvent.change(screen.getByRole('combobox', { name: '通知来源' }), { target: { value: '' } })
- fireEvent.change(screen.getByRole('combobox', { name: '通知视图' }), { target: { value: 'pending' } })
+ await userEvent.click(screen.getByRole('combobox', { name: '通知来源' }))
+ await userEvent.click(await screen.findByRole('option', { name: '全部来源' }))
+ await userEvent.click(screen.getByRole('combobox', { name: '通知视图' }))
+ await userEvent.click(await screen.findByRole('option', { name: '待处理' }))
await screen.findByText('有操作等待审批;已读不会执行操作。')
expect((screen.getByRole('button', { name: '归档' }) as HTMLButtonElement).disabled).toBe(true)
})
diff --git a/frontend/tests/portfolio/market-calendar.test.tsx b/frontend/tests/portfolio/market-calendar.test.tsx
new file mode 100644
index 000000000..2e0c7a1de
--- /dev/null
+++ b/frontend/tests/portfolio/market-calendar.test.tsx
@@ -0,0 +1,72 @@
+import { cleanup, render, screen, waitFor, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { fetchAPI } from '@panwatch/api'
+import { calendarTime, MarketCalendarStatus, type MarketStatus } from '@/components/MarketCalendarStatus'
+
+vi.mock('@panwatch/api', () => ({ fetchAPI: vi.fn() }))
+afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.clearAllMocks() })
+
+const markets: MarketStatus[] = ['CN', 'HK', 'US'].map(code => ({
+ code, status: code === 'US' ? 'after_hours' : 'closed', status_text: '',
+ is_trading: false, sessions: [], local_time: code === 'US' ? '20:30' : '08:30',
+ local_date: code === 'US' ? '2026-10-02' : '2026-10-03',
+ timezone: code === 'US' ? 'America/New_York' : 'Asia/Shanghai',
+}))
+const labelMarket = (code: string) => ({ CN: 'A股', HK: '港股', US: '美股' })[code] ?? code
+const data = {
+ timezone: 'Asia/Shanghai', markets: markets.map(market => ({
+ market: market.code, status: market.status, timezone: market.timezone,
+ local_date: market.local_date, local_time: market.local_time,
+ next_open: market.code === 'US' ? '2026-10-05T09:30:00-04:00' : '2026-10-05T09:30:00+08:00',
+ days: [{ date: '2026-10-05', reason: market.code === 'CN' ? 'holiday' : 'trading',
+ is_trading_day: market.code !== 'CN', session_times: market.code === 'US' ? [{
+ open: '2026-10-05T09:30:00-04:00', close: '2026-10-05T16:00:00-04:00',
+ }] : [] }],
+ })),
+}
+
+function beijingTimezone() {
+ const resolvedOptions = Intl.DateTimeFormat.prototype.resolvedOptions
+ vi.spyOn(Intl.DateTimeFormat.prototype, 'resolvedOptions').mockImplementation(function () {
+ return { ...resolvedOptions.call(this), timeZone: 'Asia/Shanghai' }
+ })
+}
+
+describe('market calendar', () => {
+ it('converts US opening times with daylight saving and midnight correctly', () => {
+ expect(calendarTime('2026-10-05T09:30:00-04:00', 'Asia/Shanghai', 'zh-CN')).toMatchObject({ date: '2026-10-05', clock: '21:30' })
+ expect(calendarTime('2026-11-02T09:30:00-05:00', 'Asia/Shanghai', 'zh-CN')).toMatchObject({ date: '2026-11-02', clock: '22:30' })
+ expect(calendarTime('2026-10-05T12:00:00-04:00', 'Asia/Shanghai', 'en-US').clock).toBe('00:00')
+ })
+
+ it('loads all markets only when opened and explains their different local dates', async () => {
+ beijingTimezone()
+ vi.mocked(fetchAPI).mockResolvedValue(data)
+ const view = render(
)
+ expect(fetchAPI).not.toHaveBeenCalled()
+ await userEvent.click(screen.getByRole('button', { name: '查看各市场交易日历' }))
+ const table = await screen.findByRole('table')
+ expect(within(table).getAllByRole('columnheader').map(node => node.textContent)).toEqual(['交易日期', 'A股', '港股', '美股'])
+ expect(screen.getByText('21:30')).toBeTruthy()
+ expect(screen.getByText('21:30 – 次日 04:00')).toBeTruthy()
+ expect(screen.getByText('当地 10/02 20:30')).toBeTruthy()
+ expect(screen.getByText(/状态按市场当地日期判断/)).toBeTruthy()
+ expect(fetchAPI).toHaveBeenCalledWith('/stocks/markets/calendar?days=14&timezone=Asia%2FShanghai', expect.objectContaining({ signal: expect.any(AbortSignal) }))
+ view.rerender(
({ ...market, status: 'trading' }))} labelMarket={labelMarket} />)
+ await waitFor(() => expect(fetchAPI).toHaveBeenCalledTimes(2))
+ await screen.findByRole('table')
+ await userEvent.click(screen.getByRole('button', { name: '关闭交易日历' }))
+ expect(screen.queryByRole('table')).toBeNull()
+ })
+
+ it('allows retrying a failed calendar request', async () => {
+ vi.mocked(fetchAPI).mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce(data)
+ render( )
+ await userEvent.click(screen.getByRole('button', { name: '查看各市场交易日历' }))
+ await screen.findByText('交易日历读取失败')
+ await userEvent.click(screen.getByRole('button', { name: '重试' }))
+ expect(await screen.findByRole('table')).toBeTruthy()
+ expect(fetchAPI).toHaveBeenCalledTimes(2)
+ })
+})
diff --git a/frontend/tests/portfolio/portfolio-quotes.test.ts b/frontend/tests/portfolio/portfolio-quotes.test.ts
index c1d2dc031..f74f1d170 100644
--- a/frontend/tests/portfolio/portfolio-quotes.test.ts
+++ b/frontend/tests/portfolio/portfolio-quotes.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
-import { mergePortfolioQuotes, toQuoteMap, type PortfolioSummary, type Position } from '@/lib/portfolio-quotes'
+import { applyMarketStatuses, mergePortfolioQuotes, toQuoteMap, type PortfolioSummary, type Position } from '@/lib/portfolio-quotes'
import { marketSignTextClass } from '@/lib/market-colors'
const position = (market = 'CN', symbol = '600519'): Position => ({
@@ -53,3 +53,23 @@ describe('daily portfolio quote semantics', () => {
expect(merged.total.total_daily_pnl).toBe(100)
})
})
+
+
+describe('market status transitions without new quotes', () => {
+ it('neutralizes yesterday’s quote on a holiday without changing its price or the cached source', () => {
+ const quotes = toQuoteMap([{ market: 'CN', symbol: '600519', current_price: 10, change_pct: 5,
+ daily_move_status: 'current', quote_date: '2026-09-30' }])
+ const displayed = applyMarketStatuses(quotes, [{ code: 'CN', status: 'closed', local_date: '2026-10-01' }])
+ expect(displayed['CN:600519']).toMatchObject({ current_price: 10, change_pct: null, daily_move_status: 'closed' })
+ expect(quotes['CN:600519'].change_pct).toBe(5)
+ expect(mergePortfolioQuotes(portfolio(), displayed)!.total.total_daily_pnl).toBe(0)
+ })
+
+ it('keeps final same-day movement after close and rejects a prior-day quote after reopening', () => {
+ const quotes = toQuoteMap([{ market: 'US', symbol: 'AAPL', current_price: 10, change_pct: 5,
+ daily_move_status: 'current', quote_date: '2026-10-02' }])
+ expect(applyMarketStatuses(quotes, [{ code: 'US', status: 'after_hours', local_date: '2026-10-02' }])['US:AAPL'].change_pct).toBe(5)
+ expect(applyMarketStatuses(quotes, [{ code: 'US', status: 'trading', local_date: '2026-10-05' }])['US:AAPL'])
+ .toMatchObject({ change_pct: null, daily_move_status: 'stale' })
+ })
+})
diff --git a/frontend/tests/settings/AgentPermissionsPanel.test.tsx b/frontend/tests/settings/AgentPermissionsPanel.test.tsx
index 9b6ffe7a8..05e303b53 100644
--- a/frontend/tests/settings/AgentPermissionsPanel.test.tsx
+++ b/frontend/tests/settings/AgentPermissionsPanel.test.tsx
@@ -1,53 +1,29 @@
-import { fireEvent, render, screen } from '@testing-library/react'
-import { describe, expect, it, vi } from 'vitest'
-
+import { cleanup, render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { afterEach, describe, expect, it, vi } from 'vitest'
import { AgentPermissionsPanel } from '@/components/assistant/AgentPermissionsPanel'
+afterEach(cleanup)
describe('agent tool permission settings', () => {
- it('renders safe defaults, sends a tool override and never offers destructive allow', () => {
+ it('uses shared selectors, sends overrides and locks destructive permissions', async () => {
const onChange = vi.fn()
- render(
- ,
- )
-
- expect((screen.getByLabelText('读取默认权限') as HTMLSelectElement).value).toBe('allow')
- expect((screen.getByLabelText('修改默认权限') as HTMLSelectElement).value).toBe('ask')
- expect((screen.getByLabelText('外部操作默认权限') as HTMLSelectElement).value).toBe('ask')
- const destructiveDefault = screen.getByLabelText('破坏性操作默认权限') as HTMLSelectElement
- expect(destructiveDefault.value).toBe('deny')
- expect([...destructiveDefault.options].some((option) => option.value === 'allow')).toBe(false)
-
- fireEvent.change(screen.getByLabelText('修改默认权限'), { target: { value: 'allow' } })
- expect(onChange).toHaveBeenCalledWith({
- selector_kind: 'risk',
- selector_value: 'write',
- mode: 'allow',
- risk: 'write',
- })
-
- fireEvent.change(screen.getByLabelText('创建提醒'), { target: { value: 'allow' } })
- expect(onChange).toHaveBeenCalledWith({
- selector_kind: 'tool',
- selector_value: 'create_alert',
- mode: 'allow',
- risk: 'write',
- })
-
- const destructive = screen.getByLabelText('删除提醒') as HTMLSelectElement
- expect([...destructive.options].some((option) => option.value === 'allow')).toBe(false)
+ render( )
+ expect(screen.getByRole('combobox', { name: '读取默认权限' }).textContent).toContain('允许')
+ expect((screen.getByRole('combobox', { name: '破坏性操作默认权限' }) as HTMLButtonElement).disabled).toBe(true)
+ expect((screen.getByRole('combobox', { name: '删除提醒' }) as HTMLButtonElement).disabled).toBe(true)
+ await userEvent.click(screen.getByRole('combobox', { name: '修改默认权限' }))
+ await userEvent.click(await screen.findByRole('option', { name: '直接允许' }))
+ expect(onChange).toHaveBeenCalledWith({ selector_kind: 'risk', selector_value: 'write', mode: 'allow', risk: 'write' })
+ await userEvent.click(screen.getByRole('combobox', { name: '创建提醒' }))
+ await userEvent.click(await screen.findByRole('option', { name: '直接允许' }))
+ expect(onChange).toHaveBeenCalledWith({ selector_kind: 'tool', selector_value: 'create_alert', mode: 'allow', risk: 'write' })
})
})
diff --git a/frontend/tests/settings/confirm-dialog.test.tsx b/frontend/tests/settings/confirm-dialog.test.tsx
new file mode 100644
index 000000000..0cedf4d2f
--- /dev/null
+++ b/frontend/tests/settings/confirm-dialog.test.tsx
@@ -0,0 +1,59 @@
+import { cleanup, render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { ConfirmProvider, useConfirm } from '@panwatch/base-ui/components/ui/confirm-dialog'
+
+const labels = { title: '确认', cancel: '取消', confirm: '确认' }
+afterEach(cleanup)
+function DeleteButton({ remove }: { remove: () => void }) {
+ const confirmAction = useConfirm()
+ return {
+ if (await confirmAction('确定删除账户?', { destructive: true })) remove()
+ }}>删除账户
+}
+describe('shared confirmation', () => {
+ it('waits for acceptance, focuses cancel first, and restores focus on cancellation', async () => {
+ const remove = vi.fn()
+ render( )
+ const trigger = screen.getByRole('button', { name: '删除账户' })
+ await userEvent.click(trigger)
+ expect(await screen.findByRole('dialog')).toBeTruthy()
+ expect(remove).not.toHaveBeenCalled()
+ expect(document.activeElement).toBe(screen.getByRole('button', { name: '取消' }))
+ await userEvent.keyboard('{Escape}')
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
+ expect(remove).not.toHaveBeenCalled()
+ expect(document.activeElement).toBe(trigger)
+ await userEvent.click(trigger)
+ await userEvent.click(screen.getByRole('button', { name: '确认' }))
+ await waitFor(() => expect(remove).toHaveBeenCalledTimes(1))
+ })
+
+ it('cancels pending confirmations on unmount', async () => {
+ const remove = vi.fn()
+ const view = render( )
+ await userEvent.click(screen.getByRole('button', { name: '删除账户' }))
+ view.unmount()
+ await Promise.resolve()
+ expect(remove).not.toHaveBeenCalled()
+ })
+
+ it('keeps concurrent requests in order without losing a promise', async () => {
+ const first = vi.fn(), second = vi.fn()
+ function Trigger() {
+ const ask = useConfirm()
+ return {
+ void ask('第一个请求').then(first)
+ void ask('第二个请求').then(second)
+ }}>打开
+ }
+ render( )
+ await userEvent.click(screen.getByRole('button', { name: '打开' }))
+ await userEvent.click(screen.getByRole('button', { name: '确认' }))
+ expect(first).toHaveBeenCalledWith(true)
+ expect(second).not.toHaveBeenCalled()
+ expect(screen.getByText('第二个请求')).toBeTruthy()
+ await userEvent.click(screen.getByRole('button', { name: '取消' }))
+ expect(second).toHaveBeenCalledWith(false)
+ })
+})
diff --git a/frontend/tests/settings/ui-primitives-check.test.ts b/frontend/tests/settings/ui-primitives-check.test.ts
new file mode 100644
index 000000000..77d9b25c6
--- /dev/null
+++ b/frontend/tests/settings/ui-primitives-check.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from 'vitest'
+// @ts-expect-error The repository check is a JavaScript build tool.
+import { findUiViolations } from '../../scripts/check-ui-primitives.mjs'
+
+describe('UI primitive enforcement', () => {
+ it('rejects native controls, browser dialogs and unstyled scroll areas', () => {
+ for (const source of [
+ 'const x = One ',
+ 'const x = ', 'window.confirm("Delete?")', 'globalThis.alert("Failed")',
+ 'prompt("Name")', 'const x =
',
+ 'const x =
',
+ ]) expect(findUiViolations(source).length).toBeGreaterThan(0)
+ })
+ it('accepts shared components and ignores comments containing forbidden examples', () => {
+ expect(findUiViolations(`
+ // window.confirm("Delete?") and are forbidden.
+ const ask = useConfirm()
+ await ask(message)
+ const x =
+ const y =
+ `)).toEqual([])
+ })
+})
diff --git a/frontend/tests/setup.ts b/frontend/tests/setup.ts
index 1601d1b24..9d4726739 100644
--- a/frontend/tests/setup.ts
+++ b/frontend/tests/setup.ts
@@ -19,3 +19,10 @@ afterEach(() => {
window.localStorage.removeItem('panwatch-locale')
void i18n.changeLanguage('zh-CN')
})
+
+// Radix Select relies on browser pointer capture and scrolling APIs absent in jsdom.
+if (!window.PointerEvent) window.PointerEvent = MouseEvent as typeof PointerEvent
+if (!Element.prototype.hasPointerCapture) Element.prototype.hasPointerCapture = () => false
+if (!Element.prototype.setPointerCapture) Element.prototype.setPointerCapture = () => {}
+if (!Element.prototype.releasePointerCapture) Element.prototype.releasePointerCapture = () => {}
+if (!HTMLElement.prototype.scrollIntoView) HTMLElement.prototype.scrollIntoView = () => {}
diff --git a/server.py b/server.py
index a5e6ca011..c5f923d9a 100644
--- a/server.py
+++ b/server.py
@@ -1129,8 +1129,9 @@ def build_scheduler() -> AgentScheduler:
if not agent_cls:
logger.warning(f"Agent {cfg.name} 未在 AGENT_REGISTRY 中注册")
continue
- if not cfg.schedule:
- logger.info(f"Agent {cfg.name} 未设置调度计划,跳过")
+ from src.modules.automation.scheduling_policy import schedule_plans
+ plans = schedule_plans(db, cfg)
+ if not plans:
continue
agent_kwargs = cfg.config or {}
@@ -1140,11 +1141,12 @@ def build_scheduler() -> AgentScheduler:
)
except TypeError:
agent_instance = agent_cls()
- sched.register(
- agent_instance,
- schedule=cfg.schedule,
- execution_mode=cfg.execution_mode or "batch",
- )
+ for plan in plans:
+ sched.register(
+ agent_instance, schedule=plan.schedule,
+ execution_mode=cfg.execution_mode or "batch",
+ stock_keys=plan.stock_keys, stock_agent_id=plan.stock_agent_id,
+ )
finally:
db.close()
@@ -1182,6 +1184,7 @@ def reload_scheduler() -> bool:
except Exception:
pass
scheduler = build_scheduler()
+ register_mcp_log_cleanup(scheduler)
scheduler.start()
logger.info("Agent 调度器已重载")
return True
@@ -1521,14 +1524,13 @@ def refresh_stock_cache():
threading.Thread(target=refresh_stock_cache, daemon=True).start()
- # 交易日历预热(判断周末/法定节假日是否开市)。拉取失败会自动降级为只判周末,
- # 因此这里不阻塞启动,交给后台任务;之后每日 03:00 由上下文维护调度器刷新。
+ # 预热本地近期交易日历,不请求全历史数据;未公布年度不会授权自动交易。
try:
from src.platform.scheduling.trading_calendar import refresh as refresh_trading_calendar
asyncio.create_task(refresh_trading_calendar())
except Exception as e:
- logger.warning(f"交易日历预热调度失败(降级为只判周末): {e}")
+ logger.warning(f"交易日历预热调度失败: {e}")
global scheduler, price_alert_scheduler, paper_trading_scheduler, context_maintenance_scheduler
scheduler = build_scheduler()
diff --git a/src/modules/assistant/tools.py b/src/modules/assistant/tools.py
index 7841e8228..7260a43bf 100644
--- a/src/modules/assistant/tools.py
+++ b/src/modules/assistant/tools.py
@@ -468,28 +468,25 @@ async def search_stocks_tool(_request: RunRequest, arguments: dict) -> ToolResul
)
async def get_market_status(_request: RunRequest, _arguments: dict) -> ToolResult:
+ from src.platform.scheduling import trading_calendar
markets = []
for code, definition in MARKETS.items():
try:
- is_trading = definition.is_trading_time()
+ status = trading_calendar.market_status(code)
+ is_trading = status == "trading"
except Exception: # noqa: BLE001 - calendar failures stay in the result
+ status = "unknown"
is_trading = None
sessions = [
f"{item.start.strftime('%H:%M')}-{item.end.strftime('%H:%M')}"
- for item in definition.sessions
+ for item in trading_calendar.trading_sessions(code)
]
markets.append(
{
"market": code.value,
"name": definition.name,
"timezone": definition.timezone,
- "status": (
- "trading"
- if is_trading is True
- else "closed"
- if is_trading is False
- else "unknown"
- ),
+ "status": status,
"is_trading": is_trading,
"sessions": sessions,
}
diff --git a/src/modules/automation/agent_scheduler.py b/src/modules/automation/agent_scheduler.py
index b32e67c81..673708f33 100644
--- a/src/modules/automation/agent_scheduler.py
+++ b/src/modules/automation/agent_scheduler.py
@@ -1,6 +1,6 @@
import logging
import time
-from typing import Callable, Awaitable
+from typing import Callable
from apscheduler.schedulers.asyncio import AsyncIOScheduler
@@ -10,7 +10,7 @@
from src.modules.automation.agent_runs import record_agent_run
from src.platform.observability.log_context import log_context
from src.platform.observability import otel
-from src.platform.marketdata.models import MARKETS
+from src.modules.automation.scheduling_policy import market_allowed, scoped_context
from src.platform.scheduling.schedule_parser import parse_schedule
logger = logging.getLogger(__name__)
@@ -31,7 +31,9 @@ def set_context_builder(self, builder: Callable[[str], AgentContext]):
"""设置 context 构建函数(每次执行时动态构建)"""
self.context_builder = builder
- def register(self, agent: BaseAgent, schedule: str, execution_mode: str = "batch"):
+ def register(self, agent: BaseAgent, schedule: str, execution_mode: str = "batch",
+ stock_keys: tuple[tuple[str, str], ...] | None = None,
+ stock_agent_id: int | None = None):
"""
注册 Agent 到调度器。
@@ -53,8 +55,8 @@ def register(self, agent: BaseAgent, schedule: str, execution_mode: str = "batch
self.scheduler.add_job(
self._run_agent,
trigger=trigger,
- args=[agent.name],
- id=agent.name,
+ args=[agent.name, stock_keys, stock_agent_id],
+ id=f"{agent.name}:stock:{stock_agent_id}" if stock_agent_id else agent.name,
name=agent.display_name,
replace_existing=True,
)
@@ -63,7 +65,7 @@ def register(self, agent: BaseAgent, schedule: str, execution_mode: str = "batch
# NOTE: cron/interval 解析逻辑统一放在 src/core/schedule_parser.py
- async def _run_agent(self, agent_name: str):
+ async def _run_agent(self, agent_name: str, stock_keys=None, stock_agent_id=None):
"""执行指定 Agent(动态构建 context)"""
if not self.context_builder:
logger.error("context_builder 未设置")
@@ -74,6 +76,12 @@ async def _run_agent(self, agent_name: str):
logger.error(f"Agent 未找到: {agent_name}")
return
+ # Registered scopes are known before context construction. Closed markets
+ # must not create model clients, perform collection, or emit failed runs.
+ if stock_keys is not None and not any(market_allowed(agent_name, market) for market, _symbol in stock_keys):
+ logger.debug("[调度] %s 关联市场不满足交易日/时段,跳过", agent_name)
+ return
+
start = time.monotonic()
trace_id = f"sch-{agent_name}-{int(time.time() * 1000)}"
try:
@@ -88,22 +96,29 @@ async def _run_agent(self, agent_name: str):
tags={"trigger_source": "schedule"},
):
# 每次执行时动态构建 context(获取最新配置)
- context = self.context_builder(agent_name)
+ context = (self.context_builder(agent_name, stock_agent_id)
+ if stock_agent_id is not None else self.context_builder(agent_name))
+ targets = list(context.watchlist)
+ if stock_keys is not None:
+ keys = set(stock_keys)
+ targets = [stock for stock in targets if (stock.market, stock.symbol) in keys]
+ eligible = [stock for stock in targets if market_allowed(agent_name, stock.market)]
+ if not eligible:
+ logger.debug("[调度] %s 无符合交易日/时段的关联股票,跳过", agent_name)
+ return
+ context = scoped_context(context, eligible)
logger.info(f"[调度] 开始执行 Agent: {agent.display_name}")
mode = self.execution_modes.get(agent_name, "batch")
if mode == "single" and hasattr(agent, "run_single"):
processed = 0
- skipped = 0
+ skipped = len(targets) - len(eligible)
errors: list[str] = []
notify_attempted = False
notify_sent = False
for stock in list(context.watchlist):
- market_def = MARKETS.get(stock.market)
- if market_def and not market_def.is_trading_time():
+ # Recheck after preceding symbols may have taken minutes to analyze.
+ if not market_allowed(agent_name, stock.market):
skipped += 1
- logger.info(
- f"[调度] 跳过 {agent.display_name} {stock.symbol}({market_def.name} 非交易时段)"
- )
continue
try:
with kline_source(f"agent:{agent_name}"):
@@ -126,7 +141,7 @@ async def _run_agent(self, agent_name: str):
f"{stock.symbol}: {safe_ai_error_message(e)}"
)
logger.info(
- f"[调度] Agent 单只模式执行完成: {agent.display_name}(执行{processed},跳过{skipped},共{len(context.watchlist)})"
+ f"[调度] Agent 单只模式执行完成: {agent.display_name}(执行{processed},跳过{skipped},共{len(targets)})"
)
# An idle poll is not a completed report and cannot resolve
# a prior failure episode. Keep its scheduler logs only.
@@ -136,7 +151,7 @@ async def _run_agent(self, agent_name: str):
record_agent_run(
agent_name=agent_name,
status="failed" if errors else "success",
- result=f"single mode executed {processed}, skipped {skipped}, total {len(context.watchlist)}",
+ result=f"single mode executed {processed}, skipped {skipped}, total {len(targets)}",
error="; ".join(errors),
notify_attempted=notify_attempted,
notify_sent=notify_sent,
diff --git a/src/modules/automation/api/agents.py b/src/modules/automation/api/agents.py
index 0d523ca3b..ef1bf0ceb 100644
--- a/src/modules/automation/api/agents.py
+++ b/src/modules/automation/api/agents.py
@@ -12,7 +12,8 @@
from src.platform.persistence.database import get_db
from src.platform.persistence.models import AgentConfig, AgentRun, LogEntry
-from src.platform.scheduling.schedule_parser import preview_schedule
+from src.modules.automation.scheduling_policy import schedule_plans, request_scheduler_reload
+from src.platform.scheduling.schedule_parser import parse_schedule, preview_schedule
from src.platform.scheduling.schedule_parser import count_runs_within
from src.platform.runtime.config import Settings
from src.web.errors import ai_api_error, api_error
@@ -128,13 +129,15 @@ def agents_health(
for a in agents:
next_runs: list[str] = []
- if a.enabled and (a.schedule or "").strip():
+ if a.enabled:
try:
- runs = preview_schedule(a.schedule, count=3, timezone=tz)
- next_runs = [r.isoformat() for r in runs]
- next_24h_count += count_runs_within(
- a.schedule, start=now, end=horizon, timezone=tz
- )
+ runs = []
+ for plan in schedule_plans(db, a):
+ runs.extend(preview_schedule(plan.schedule, count=3, timezone=tz, start=now,
+ markets=plan.markets, trading_hours_only=a.name == "intraday_monitor"))
+ next_24h_count += count_runs_within(plan.schedule, start=now, end=horizon,
+ timezone=tz, markets=plan.markets, trading_hours_only=a.name == "intraday_monitor")
+ next_runs = [r.isoformat() for r in sorted(set(runs))[:3]]
except Exception:
next_runs = []
@@ -283,6 +286,11 @@ def update_agent(
if not agent:
raise api_error(404, "agent_not_found", f"Agent {agent_name} 不存在")
+ if update.schedule:
+ try:
+ parse_schedule(update.schedule)
+ except ValueError as exc:
+ raise api_error(400, "agent_schedule_invalid", "调度表达式无法解析") from exc
for key, value in update.model_dump(exclude_unset=True).items():
if key == "config":
value = _public_agent_config(value)
@@ -296,52 +304,52 @@ def update_agent(
db.commit()
db.refresh(agent)
+ request_scheduler_reload()
return _agent_to_response(agent)
@router.get("/schedule/preview")
-def preview_schedule_expr(schedule: str, count: int = 5):
- """预览某个 schedule 表达式接下来几次触发时间(按调度时区)"""
+def preview_schedule_expr(schedule: str, count: int = Query(default=5, ge=1, le=50),
+ agent_name: str = "", market: str = "", db: Session = Depends(get_db)):
+ """Preview actual eligible runs for an agent/market; keep raw Cron validation available."""
tz = Settings().app_timezone or "UTC"
- if not schedule:
- return {"schedule": "", "timezone": tz, "next_runs": []}
-
+ markets = None
+ if market:
+ from src.platform.scheduling.trading_calendar import _to_market_code
+ code = _to_market_code(market)
+ if code is None:
+ raise api_error(400, "market_invalid", "市场代码无效")
+ markets = [code]
+ elif agent_name:
+ agent = db.query(AgentConfig).filter(AgentConfig.name == agent_name).first()
+ if agent is None:
+ raise api_error(404, "agent_not_found", "Agent 不存在")
+ plans = schedule_plans(db, agent, global_schedule=schedule)
+ markets = sorted({market for plan in plans if plan.stock_agent_id is None for market in plan.markets})
try:
- runs = preview_schedule(schedule, count=count, timezone=tz)
- except Exception as e:
- logger.warning("调度表达式无法解析: %s", e)
- raise api_error(400, "agent_schedule_invalid", "调度表达式无法解析") from e
-
- return {
- "schedule": schedule,
- "timezone": tz,
- "next_runs": [r.isoformat() for r in runs],
- }
+ runs = preview_schedule(schedule, count=count, timezone=tz, markets=markets,
+ trading_hours_only=agent_name == "intraday_monitor") if schedule else []
+ except ValueError as exc:
+ raise api_error(400, "agent_schedule_invalid", "调度表达式无法解析") from exc
+ return {"schedule": schedule, "timezone": tz, "next_runs": [r.isoformat() for r in runs],
+ "calendar_filtered": markets is not None}
@router.get("/{agent_name}/schedule/preview")
-def preview_agent_schedule(
- agent_name: str, count: int = 5, db: Session = Depends(get_db)
-):
- """预览某个 Agent 接下来几次的触发时间(按调度时区)"""
+def preview_agent_schedule(agent_name: str, count: int = Query(default=5, ge=1, le=50),
+ db: Session = Depends(get_db)):
tz = Settings().app_timezone or "UTC"
agent = db.query(AgentConfig).filter(AgentConfig.name == agent_name).first()
if not agent:
raise api_error(404, "agent_not_found", f"Agent {agent_name} 不存在")
- if not agent.schedule:
- return {"schedule": "", "timezone": tz, "next_runs": []}
-
try:
- runs = preview_schedule(agent.schedule, count=count, timezone=tz)
- except Exception as e:
- logger.warning("Agent %s 调度表达式无法解析: %s", agent_name, e)
- raise api_error(400, "agent_schedule_invalid", "调度表达式无法解析") from e
-
- return {
- "schedule": agent.schedule,
- "timezone": tz,
- "next_runs": [r.isoformat() for r in runs],
- }
+ runs = [run for plan in schedule_plans(db, agent)
+ for run in preview_schedule(plan.schedule, count=count, timezone=tz,
+ markets=plan.markets, trading_hours_only=agent_name == "intraday_monitor")]
+ except ValueError as exc:
+ raise api_error(400, "agent_schedule_invalid", "调度表达式无法解析") from exc
+ return {"schedule": agent.schedule, "timezone": tz,
+ "next_runs": [r.isoformat() for r in sorted(set(runs))[:count]], "calendar_filtered": True}
@router.delete("/{agent_name}")
@@ -358,6 +366,7 @@ def delete_agent(agent_name: str, db: Session = Depends(get_db)):
db.delete(agent)
db.commit()
+ request_scheduler_reload()
return {"ok": True, "message": f"Agent {agent_name} 已删除"}
diff --git a/src/modules/automation/scheduling_policy.py b/src/modules/automation/scheduling_policy.py
new file mode 100644
index 000000000..e4cbad6f8
--- /dev/null
+++ b/src/modules/automation/scheduling_policy.py
@@ -0,0 +1,77 @@
+"""Effective stock schedules and market eligibility shared by execution/preview."""
+from __future__ import annotations
+
+from copy import copy
+from dataclasses import dataclass, replace
+import logging
+import sys
+
+from src.modules.automation.base import PortfolioInfo
+from src.platform.marketdata.models import MARKETS
+from src.platform.persistence.models import StockAgent
+from src.platform.scheduling.trading_calendar import is_trading_day
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True)
+class SchedulePlan:
+ schedule: str
+ stock_keys: tuple[tuple[str, str], ...]
+ stock_agent_id: int | None = None
+
+ @property
+ def markets(self) -> list[str]:
+ return sorted({market for market, _symbol in self.stock_keys})
+
+
+def schedule_plans(db, agent, *, global_schedule: str | None = None) -> list[SchedulePlan]:
+ inherited = []
+ plans = []
+ bindings = db.query(StockAgent).filter(StockAgent.agent_name == agent.name).all()
+ for binding in bindings:
+ stock = binding.stock
+ if stock is None:
+ continue
+ key = (stock.market, stock.symbol)
+ # Batch reports have a shared schedule; per-stock overrides apply to single mode.
+ if agent.execution_mode == "single" and (binding.schedule or "").strip():
+ plans.append(SchedulePlan(binding.schedule.strip(), (key,), binding.id))
+ else:
+ inherited.append(key)
+ schedule = agent.schedule if global_schedule is None else global_schedule
+ if schedule and inherited:
+ plans.insert(0, SchedulePlan(schedule, tuple(inherited)))
+ return plans
+
+
+def market_allowed(agent_name: str, market, dt=None) -> bool:
+ if agent_name == "intraday_monitor":
+ definition = MARKETS.get(market)
+ return definition is not None and definition.is_trading_time(dt)
+ return is_trading_day(market, dt)
+
+
+def scoped_context(context, watchlist):
+ """Filter both report inputs and positions without mutating the caller's context."""
+ scoped = copy(context)
+ if hasattr(context, "config"):
+ scoped.config = copy(context.config)
+ scoped.config.watchlist = watchlist
+ else:
+ scoped.watchlist = watchlist
+ if isinstance(getattr(context, "portfolio", None), PortfolioInfo):
+ keys = {(stock.market, stock.symbol) for stock in watchlist}
+ scoped.portfolio = PortfolioInfo(accounts=[replace(account, positions=[
+ p for p in account.positions if (p.market, p.symbol) in keys
+ ]) for account in context.portfolio.accounts])
+ return scoped
+
+
+def request_scheduler_reload() -> None:
+ """Reload on the owning event loop, including updates from FastAPI worker threads."""
+ runtime = sys.modules.get("server")
+ scheduler = getattr(runtime, "scheduler", None)
+ loop = getattr(getattr(scheduler, "scheduler", None), "_eventloop", None)
+ if runtime is not None and loop is not None and loop.is_running():
+ loop.call_soon_threadsafe(runtime.reload_scheduler)
diff --git a/src/modules/automation/tradingagents/operations.py b/src/modules/automation/tradingagents/operations.py
index fc074e632..b28deaded 100644
--- a/src/modules/automation/tradingagents/operations.py
+++ b/src/modules/automation/tradingagents/operations.py
@@ -166,12 +166,15 @@ def fire_and_forget_trigger(stock: Any, source_agent: str = "intraday_monitor")
async def _run():
try:
+ from src.platform.scheduling.trading_calendar import market_status
+ if market_status(getattr(stock, "market", None)) != "trading":
+ return
await trigger_agent_for_stock(
"tradingagents",
stock,
stock_agent_id=None,
bypass_throttle=True,
- bypass_market_hours=True,
+ bypass_market_hours=False,
suppress_notify=False,
trace_id=trace_id,
force_refresh=False,
@@ -202,6 +205,9 @@ def try_auto_trigger(stock: Any, source_agent: str = "intraday_monitor") -> str
供 intraday_monitor.analyze 完成后调用。返回 trace_id 或 None。
"""
+ from src.platform.scheduling.trading_calendar import market_status
+ if market_status(getattr(stock, "market", None)) != "trading":
+ return None
symbol = getattr(stock, "symbol", "") or ""
change_pct = getattr(stock, "change_pct", None)
diff --git a/src/modules/market/api/stocks.py b/src/modules/market/api/stocks.py
index dcc514d42..1e0f83f59 100644
--- a/src/modules/market/api/stocks.py
+++ b/src/modules/market/api/stocks.py
@@ -22,6 +22,8 @@
from src.platform.marketdata.models import MarketCode, MARKETS
from src.platform.marketdata.quote_display import daily_quote_fields
from src.platform.scheduling import trading_calendar
+from src.platform.scheduling.schedule_parser import parse_schedule
+from src.modules.automation.scheduling_policy import request_scheduler_reload
from src.modules.automation.agent_catalog import AGENT_KIND_WORKFLOW, infer_agent_kind
from src.web.errors import api_error
@@ -147,6 +149,7 @@ def get_market_status():
"is_trading": is_trading,
"sessions": sessions_desc,
"local_time": now.strftime("%H:%M"),
+ "local_date": now.date().isoformat(),
"timezone": market_def.timezone,
})
except Exception as e:
@@ -167,6 +170,27 @@ def get_market_status():
return result
+@router.get("/markets/calendar")
+def get_market_calendars(days: int = Query(default=14, ge=1, le=31), timezone: str = "Asia/Shanghai"):
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
+ try:
+ display_tz = ZoneInfo(timezone)
+ except (ValueError, ZoneInfoNotFoundError) as exc:
+ raise api_error(400, "timezone_invalid", "时区无效") from exc
+ now = trading_calendar._now_in_market_tz(MarketCode.CN).astimezone(display_tz)
+ return {"timezone": timezone, "start_date": now.date().isoformat(),
+ "markets": [trading_calendar.upcoming_calendar(code, days=days, start_date=now.date())
+ for code in MARKETS]}
+
+
+@router.get("/markets/{market}/calendar")
+def get_market_calendar(market: str, days: int = Query(default=14, ge=1, le=31)):
+ code = trading_calendar._to_market_code(market)
+ if code is None:
+ raise api_error(400, "market_invalid", "市场代码无效")
+ return trading_calendar.upcoming_calendar(code, days=days)
+
+
@router.get("/search")
def search(q: str = Query("", min_length=1), market: str = Query("")):
"""模糊搜索股票(代码/名称)"""
@@ -305,6 +329,7 @@ def delete_stock(stock_id: int, db: Session = Depends(get_db)):
db.delete(db_stock)
db.commit()
+ request_scheduler_reload()
return {"ok": True}
@@ -316,6 +341,11 @@ def update_stock_agents(stock_id: int, body: StockAgentUpdate, db: Session = Dep
raise api_error(404, "stock_not_found", "股票不存在")
for item in body.agents:
+ if item.schedule:
+ try:
+ parse_schedule(item.schedule)
+ except ValueError as exc:
+ raise api_error(400, "agent_schedule_invalid", "调度表达式无法解析") from exc
agent = db.query(AgentConfig).filter(AgentConfig.name == item.agent_name).first()
if not agent:
raise api_error(400, "agent_not_found", f"Agent {item.agent_name} 不存在")
@@ -336,6 +366,7 @@ def update_stock_agents(stock_id: int, body: StockAgentUpdate, db: Session = Dep
db.commit()
db.refresh(db_stock)
+ request_scheduler_reload()
return _stock_to_response(db_stock, _agent_display_names(db, [db_stock]))
diff --git a/src/modules/market/price_alert_engine.py b/src/modules/market/price_alert_engine.py
index 892b2890d..018f0df6e 100644
--- a/src/modules/market/price_alert_engine.py
+++ b/src/modules/market/price_alert_engine.py
@@ -15,6 +15,7 @@
from src.platform.notifications.notifier import NotifierManager
from src.platform.marketdata.marketdata_client import md_quote_rows
from src.platform.marketdata.models import MarketCode, MARKETS
+from src.platform.marketdata.quote_display import quote_date_is_current
from src.platform.persistence.database import SessionLocal
from src.platform.persistence.models import NotifyChannel, PriceAlertHit, PriceAlertRule, Stock
from src.platform.language import resolve_report_language
@@ -49,8 +50,8 @@ def _is_trading_time(market: MarketCode) -> bool:
return market_def.is_trading_time()
-def _day_key(now: datetime) -> str:
- return now.astimezone(timezone.utc).strftime("%Y-%m-%d")
+def _day_key(now: datetime, market: str) -> str:
+ return now.astimezone(MARKETS[_to_market(market)].get_tz()).strftime("%Y-%m-%d")
def _minute_bucket(now: datetime) -> str:
@@ -235,11 +236,17 @@ def _can_trigger(
if now > exp:
return False, "expired"
+ # Automatic alerts always require a confirmed trading day. "always"
+ # means all hours on that day; dry-run tests may inspect historical quotes.
+ if not bypass_market_hours:
+ from src.platform.scheduling.trading_calendar import is_trading_day
+ if not is_trading_day(rule.stock.market):
+ return False, "non_trading_day"
if rule.market_hours_mode == "trading_only" and not bypass_market_hours:
if not _is_trading_time(_to_market(rule.stock.market)):
return False, "non_trading"
- today = _day_key(now)
+ today = _day_key(now, rule.stock.market)
if (rule.trigger_date or "") != today:
rule.trigger_date = today
rule.trigger_count_today = 0
@@ -338,6 +345,7 @@ async def scan_once(
dry_run: bool = False,
bypass_market_hours: bool = False,
) -> dict:
+ bypass_market_hours = bool(bypass_market_hours and dry_run)
now = _utc_now()
db = SessionLocal()
try:
@@ -348,7 +356,10 @@ async def scan_once(
if not rules:
return {"total_rules": 0, "triggered": 0, "skipped": 0, "items": []}
- stocks = [r.stock for r in rules if r.stock is not None]
+ # Apply the gate before any quote/K-line request.
+ gates = {r.id: self._can_trigger(r, now, bypass_market_hours=bypass_market_hours)
+ for r in rules if r.stock is not None}
+ stocks = [r.stock for r in rules if r.stock is not None and gates[r.id][0]]
quote_map = await self._fetch_quotes_map(stocks)
items: list[dict] = []
@@ -361,6 +372,11 @@ async def scan_once(
skipped += 1
items.append({"rule_id": rule.id, "status": "no_stock"})
continue
+ can, reason = gates[rule.id]
+ if not can:
+ skipped += 1
+ items.append({"rule_id": rule.id, "status": "gated", "reason": reason})
+ continue
market = _to_market(stock.market)
quote = quote_map.get((market.value, stock.symbol))
if not quote:
@@ -368,6 +384,11 @@ async def scan_once(
items.append({"rule_id": rule.id, "status": "no_quote"})
continue
+ if not dry_run and not quote_date_is_current(stock.market, quote):
+ skipped += 1
+ items.append({"rule_id": rule.id, "status": "stale_quote"})
+ continue
+
can, reason = self._can_trigger(
rule, now, bypass_market_hours=bypass_market_hours
)
@@ -413,7 +434,7 @@ async def scan_once(
rule.last_trigger_at = now
rule.last_trigger_price = _safe_float(quote.get("current_price"))
rule.trigger_count_today = int(rule.trigger_count_today or 0) + 1
- rule.trigger_date = _day_key(now)
+ rule.trigger_date = _day_key(now, rule.stock.market)
if rule.repeat_mode == "once":
rule.enabled = False
diff --git a/src/modules/paper_trading/paper_trading_engine.py b/src/modules/paper_trading/paper_trading_engine.py
index ef6782dbf..7ec33e06a 100644
--- a/src/modules/paper_trading/paper_trading_engine.py
+++ b/src/modules/paper_trading/paper_trading_engine.py
@@ -11,7 +11,8 @@
from sqlalchemy.orm import Session
from src.platform.marketdata.marketdata_client import md_quote_rows
-from src.platform.marketdata.models import MarketCode, MARKETS
+from src.platform.marketdata.models import MarketCode
+from src.platform.marketdata.quote_display import quote_date_is_current
from src.platform.persistence.database import SessionLocal
from src.platform.persistence.models import (
PaperTradingAccount,
@@ -87,11 +88,8 @@ def _to_market(market: str) -> MarketCode:
def _is_trading_time(market: str) -> bool:
- mc = _to_market(market)
- market_def = MARKETS.get(mc)
- if not market_def:
- return False
- return market_def.is_trading_time()
+ from src.platform.scheduling.trading_calendar import market_status
+ return market_status(market) == "trading"
def _safe_float(v: Any) -> float | None:
@@ -276,11 +274,15 @@ def _check_entries(
self, db: Session, account: PaperTradingAccount,
) -> tuple[int, set[tuple[str, str]], list[tuple[PaperTradingPosition, StrategySignalRun | None]]]:
"""检查可入场的策略信号,自动建仓。返回 (建仓数, 新建仓股票key集合, 建仓事件列表)。"""
+ active_markets = [market for market in ALL_MARKETS if _is_trading_time(market)]
+ if not active_markets:
+ return 0, set(), []
# 查询最新活跃买入信号
query = (
db.query(StrategySignalRun)
.filter(
StrategySignalRun.status == "active",
+ StrategySignalRun.stock_market.in_(active_markets),
StrategySignalRun.action.in_(["buy", "add"]),
StrategySignalRun.entry_low.isnot(None),
StrategySignalRun.entry_high.isnot(None),
@@ -331,9 +333,11 @@ def _check_entries(
opened = 0
for sig in candidates:
+ if not _is_trading_time(sig.stock_market):
+ continue
key = (sig.stock_market, sig.stock_symbol)
quote = quotes.get(key)
- if not quote:
+ if not quote or not quote_date_is_current(sig.stock_market, quote):
continue
current_price = _safe_float(quote.get("current_price"))
if current_price is None or current_price <= 0:
@@ -492,9 +496,12 @@ def _check_exits(
) -> tuple[int, list[tuple[PaperTradingPosition, PaperTradingTrade]]]:
"""检查持仓止损/止盈/信号反转,自动平仓。skip_keys 中的股票跳过(本轮新建仓)。"""
exit_events: list[tuple[PaperTradingPosition, PaperTradingTrade]] = []
+ active_markets = [market for market in ALL_MARKETS if _is_trading_time(market)]
+ if not active_markets:
+ return 0, exit_events
positions = (
db.query(PaperTradingPosition)
- .filter(PaperTradingPosition.status == "open")
+ .filter(PaperTradingPosition.status == "open", PaperTradingPosition.stock_market.in_(active_markets))
.all()
)
if not positions:
@@ -506,12 +513,16 @@ def _check_exits(
closed = 0
for pos in positions:
+ if not _is_trading_time(pos.stock_market):
+ continue
# 跳过本轮刚建仓的持仓
if skip_keys and (pos.stock_symbol, pos.stock_market) in skip_keys:
continue
key = (pos.stock_market, pos.stock_symbol)
quote = quotes.get(key)
- current_price = _safe_float(quote.get("current_price")) if quote else None
+ if not quote or not quote_date_is_current(pos.stock_market, quote):
+ continue
+ current_price = _safe_float(quote.get("current_price"))
if current_price is None or current_price <= 0:
continue
@@ -673,15 +684,18 @@ def close_position_manual(self, position_id: int) -> dict:
if not pos:
return {"ok": False, "error": "持仓不存在或已平仓"}
+ if not _is_trading_time(pos.stock_market):
+ return {"ok": False, "error": "该市场当前非交易时段,无法成交"}
+
# 获取最新报价(走 flag 门控的 md_quote_rows,支持故障转移)
mc = _to_market(pos.stock_market)
rows = md_quote_rows([pos.stock_symbol], mc.value)
- exit_price = pos.current_price or pos.entry_price
- if rows:
- p = _safe_float(rows[0].get("current_price"))
- if p and p > 0:
- exit_price = p
+ exit_price = _safe_float(rows[0].get("current_price")) if rows else None
+ if not exit_price or exit_price <= 0 or not quote_date_is_current(pos.stock_market, rows[0]):
+ return {"ok": False, "error": "暂无有效报价,无法成交"}
+ if not _is_trading_time(pos.stock_market):
+ return {"ok": False, "error": "该市场当前非交易时段,无法成交"}
trade = self._close_position(db, account, pos, exit_price, "manual")
self._update_account_metrics(db, account)
diff --git a/src/modules/paper_trading/paper_trading_notifier.py b/src/modules/paper_trading/paper_trading_notifier.py
index e431b88ff..0e69f7f58 100644
--- a/src/modules/paper_trading/paper_trading_notifier.py
+++ b/src/modules/paper_trading/paper_trading_notifier.py
@@ -6,6 +6,7 @@
from typing import Any
from src.platform.notifications.notifier import NotifierManager
+from src.platform.scheduling import trading_calendar as calendar
from src.platform.persistence.database import SessionLocal
from src.platform.persistence.models import (
AppSettings,
@@ -274,11 +275,13 @@ def _format_daily_summary(
positions: list[PaperTradingPosition],
account: PaperTradingAccount,
english: bool = False,
+ total_equity: float | None = None,
) -> tuple[str, str]:
"""格式化日终摘要,返回 (title, body)。"""
# 总资产
positions_value = sum((p.current_price or p.entry_price) * p.quantity for p in positions)
- total_equity = account.current_capital + positions_value
+ if total_equity is None:
+ total_equity = account.current_capital + positions_value
unrealized = sum(p.unrealized_pnl or 0 for p in positions)
title = "[Paper trading daily summary]" if english else "【模拟盘日终摘要】"
@@ -343,7 +346,7 @@ async def notify_exit(pos: dict, trade: dict) -> None:
logger.exception("[模拟盘通知] 平仓通知发送失败")
-async def send_premarket_plan() -> None:
+async def send_premarket_plan(*, markets: list[str] | None = None) -> None:
"""盘前计划通知。"""
try:
if not _is_mode_enabled("pt_notify_premarket"):
@@ -358,32 +361,28 @@ async def send_premarket_plan() -> None:
if not account or not account.enabled:
return
- # 按投资比例排除不投入(比例为 0)的市场
- from src.modules.paper_trading.paper_trading_engine import ALL_MARKETS, market_allocations_or_default
+ from src.modules.paper_trading.paper_trading_engine import market_allocations_or_default
alloc = market_allocations_or_default(account)
- excluded = [m for m in ALL_MARKETS if alloc.get(m, 0.0) <= 0]
- query = (
- db.query(StrategySignalRun)
- .filter(
+ for market in calendar.eligible_markets(markets):
+ if alloc.get(market, 0.0) <= 0:
+ continue
+ signals = (db.query(StrategySignalRun).filter(
StrategySignalRun.status == "active",
+ StrategySignalRun.stock_market == market,
StrategySignalRun.action.in_(["buy", "add"]),
StrategySignalRun.entry_low.isnot(None),
StrategySignalRun.entry_high.isnot(None),
- )
- )
- if excluded:
- query = query.filter(StrategySignalRun.stock_market.notin_(excluded))
- signals = query.order_by(StrategySignalRun.rank_score.desc()).all()
-
- title, body = _format_premarket_plan(signals, account, english=_report_is_english())
- await mgr.notify(title, body)
+ ).order_by(StrategySignalRun.rank_score.desc()).all())
+ title, body = _format_premarket_plan(signals, account, english=_report_is_english())
+ day = calendar._resolve_date(calendar._to_market_code(market), None)
+ await mgr.notify(f"{title} {market} · {day}", body)
finally:
db.close()
except Exception:
logger.exception("[模拟盘通知] 盘前计划发送失败")
-async def send_daily_summary() -> None:
+async def send_daily_summary(*, markets: list[str] | None = None) -> None:
"""日终摘要通知。"""
try:
if not _is_mode_enabled("pt_notify_summary"):
@@ -398,27 +397,24 @@ async def send_daily_summary() -> None:
if not account or not account.enabled:
return
- from datetime import datetime, timezone, timedelta
- now = datetime.now(timezone.utc)
- today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
-
- # 当日已平仓
- trades = (
- db.query(PaperTradingTrade)
- .filter(PaperTradingTrade.closed_at >= today_start)
- .order_by(PaperTradingTrade.closed_at.desc())
- .all()
- )
-
- # 持仓中
- positions = (
- db.query(PaperTradingPosition)
- .filter(PaperTradingPosition.status == "open")
- .all()
- )
-
- title, body = _format_daily_summary(trades, positions, account, english=_report_is_english())
- await mgr.notify(title, body)
+ from src.modules.paper_trading.paper_trading_engine import market_allocations_or_default
+ alloc = market_allocations_or_default(account)
+ all_positions = db.query(PaperTradingPosition).filter(PaperTradingPosition.status == "open").all()
+ total_equity = account.current_capital + sum((p.current_price or p.entry_price) * p.quantity for p in all_positions)
+ for market in calendar.eligible_markets(markets):
+ start, end = calendar.local_day_bounds(market)
+ trades = (db.query(PaperTradingTrade).filter(
+ PaperTradingTrade.stock_market == market,
+ PaperTradingTrade.closed_at >= start,
+ PaperTradingTrade.closed_at < end,
+ ).order_by(PaperTradingTrade.closed_at.desc()).all())
+ positions = [p for p in all_positions if p.stock_market == market]
+ if alloc.get(market, 0.0) <= 0 and not positions and not trades:
+ continue
+ title, body = _format_daily_summary(trades, positions, account,
+ english=_report_is_english(), total_equity=total_equity)
+ day = calendar._resolve_date(calendar._to_market_code(market), None)
+ await mgr.notify(f"{title} {market} · {day}", body)
finally:
db.close()
except Exception:
diff --git a/src/modules/paper_trading/paper_trading_scheduler.py b/src/modules/paper_trading/paper_trading_scheduler.py
index 4424b0ac9..13687448f 100644
--- a/src/modules/paper_trading/paper_trading_scheduler.py
+++ b/src/modules/paper_trading/paper_trading_scheduler.py
@@ -3,11 +3,13 @@
from __future__ import annotations
import logging
+from datetime import datetime, timedelta
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from src.modules.paper_trading.paper_trading_engine import ENGINE
-from src.platform.scheduling.trading_calendar import any_market_trading_day
+from src.platform.scheduling import trading_calendar as calendar
+from src.platform.scheduling.exchange_calendar_data import EARLY_CLOSES
from src.platform.marketdata.models import MARKETS, MarketCode
logger = logging.getLogger(__name__)
@@ -55,25 +57,33 @@ async def _scan_job(self):
finally:
self._running = False
- async def _premarket_job(self):
+ async def _premarket_job(self, market: str | None = None):
"""盘前计划通知。非交易日(周末/节假日)跳过。"""
- if not any_market_trading_day():
+ markets = calendar.eligible_markets([market] if market else None)
+ if not markets:
logger.debug("[模拟盘] 非交易日,跳过盘前计划通知")
return
try:
from src.modules.paper_trading.paper_trading_notifier import send_premarket_plan
- await send_premarket_plan()
+ await send_premarket_plan(markets=markets)
except Exception as e:
logger.exception(f"[模拟盘] 盘前计划通知异常: {e}")
- async def _summary_job(self):
+ async def _summary_job(self, market: str | None = None):
"""日终摘要通知。非交易日(周末/节假日)跳过。"""
- if not any_market_trading_day():
+ markets = calendar.eligible_markets([market] if market else None)
+ if not markets:
logger.debug("[模拟盘] 非交易日,跳过日终摘要通知")
return
+ if market:
+ now = calendar._now_in_market_tz(calendar._to_market_code(market))
+ sessions = calendar.trading_sessions(market, now)
+ due = datetime.combine(now.date(), sessions[-1].end, now.tzinfo) + timedelta(minutes=30)
+ if (now.hour, now.minute) != (due.hour, due.minute):
+ return
try:
from src.modules.paper_trading.paper_trading_notifier import send_daily_summary
- await send_daily_summary()
+ await send_daily_summary(markets=markets)
except Exception as e:
logger.exception(f"[模拟盘] 日终摘要通知异常: {e}")
@@ -88,28 +98,25 @@ def start(self):
coalesce=True,
max_instances=1,
)
- # 盘前计划 - 每天 09:00
- self.scheduler.add_job(
- self._premarket_job,
- "cron",
- hour=9,
- minute=0,
- id="paper_trading_premarket",
- replace_existing=True,
- coalesce=True,
- max_instances=1,
- )
- # 日终摘要 - 每天 15:30
- self.scheduler.add_job(
- self._summary_job,
- "cron",
- hour=15,
- minute=30,
- id="paper_trading_summary",
- replace_existing=True,
- coalesce=True,
- max_instances=1,
- )
+ # Built-in paper notifications use each exchange's local clock. Agent
+ # schedules and the configured scan interval are deliberately unchanged.
+ for code, definition in MARKETS.items():
+ self.scheduler.add_job(
+ self._premarket_job, "cron", hour=9, minute=0,
+ timezone=definition.timezone, args=[code.value],
+ id=f"paper_trading_premarket_{code.value}", replace_existing=True,
+ coalesce=True, max_instances=1,
+ )
+ closes = {definition.sessions[-1].end}
+ closes.update(close for (market, _day), close in EARLY_CLOSES.items() if market == code.value)
+ for close in closes:
+ due = datetime.combine(datetime.today(), close) + timedelta(minutes=30)
+ self.scheduler.add_job(
+ self._summary_job, "cron", hour=due.hour, minute=due.minute,
+ timezone=definition.timezone, args=[code.value],
+ id=f"paper_trading_summary_{code.value}_{due:%H%M}", replace_existing=True,
+ coalesce=True, max_instances=1,
+ )
self.scheduler.start()
from src.platform.scheduling.scheduler_registry import register
register("paper_trading", self.scheduler)
diff --git a/src/modules/research/context_scheduler.py b/src/modules/research/context_scheduler.py
index c7b24fe00..1fef55759 100644
--- a/src/modules/research/context_scheduler.py
+++ b/src/modules/research/context_scheduler.py
@@ -188,9 +188,10 @@ async def evaluate_once(self) -> dict:
async def _refresh_opportunities_job(self):
"""定时刷新机会池(候选 + 策略信号)。全市场休市日跳过。"""
- from src.platform.scheduling.trading_calendar import any_market_trading_day
+ from src.platform.scheduling.trading_calendar import eligible_markets
- if not any_market_trading_day():
+ markets = eligible_markets()
+ if not markets:
logger.debug("[上下文维护] 非交易日,跳过机会刷新")
return
if self._refreshing:
@@ -202,6 +203,7 @@ async def _refresh_opportunities_job(self):
result = await asyncio.to_thread(
refresh_strategy_signals,
rebuild_candidates=True,
+ markets=markets,
max_inputs=500,
market_scan_limit=80,
max_kline_symbols=60,
@@ -241,11 +243,7 @@ async def cleanup_once(self) -> dict:
)
async def _refresh_trading_calendar_job(self):
- """每日刷新 A 股交易日历。
-
- 日历只覆盖到当年年底,长跑实例跨年后会超出覆盖范围而降级为"只判周末",
- 因此每天凌晨拉一次。安排在各类盘前通知之前,保证当天判断用的是新日历。
- """
+ """刷新本地近期窗口,不联网拉取历史日历。"""
from src.platform.scheduling.trading_calendar import refresh
try:
diff --git a/src/modules/research/prediction_outcome.py b/src/modules/research/prediction_outcome.py
index 605c5a605..e9aacc69b 100644
--- a/src/modules/research/prediction_outcome.py
+++ b/src/modules/research/prediction_outcome.py
@@ -4,6 +4,7 @@
from datetime import date, datetime, timedelta
from src.platform.marketdata.collectors.kline_collector import KlineCollector
+from src.platform.marketdata.outcome_prices import completed_outcome_bar
from src.modules.research.context_store import (
list_pending_prediction_outcomes,
mark_agent_prediction_outcome,
@@ -148,7 +149,8 @@ def evaluate_pending_prediction_outcomes(
if base_day is None:
stats["skipped_no_price"] += 1
continue
- outcome_price = _find_close_after_n_trading_days(klines, base_day, horizon)
+ bar = completed_outcome_bar(klines, base_day, horizon, market)
+ outcome_price = bar[1] if bar else None
else:
target_day = pred_day + timedelta(days=horizon)
if target_day > today:
diff --git a/src/modules/strategy/entry_candidates.py b/src/modules/strategy/entry_candidates.py
index 066c71e58..2a400ef3e 100644
--- a/src/modules/strategy/entry_candidates.py
+++ b/src/modules/strategy/entry_candidates.py
@@ -1,5 +1,7 @@
from __future__ import annotations
+from src.platform.marketdata.outcome_prices import completed_outcome_bar
+
import asyncio
import logging
from datetime import date, datetime, timedelta
@@ -973,7 +975,7 @@ def _merge_market_scan_seed(
return added
-def _load_market_scan_inputs(limit_per_market: int = 60) -> dict[str, dict]:
+def _load_market_scan_inputs(limit_per_market: int = 60, *, markets: list[str] | None = None) -> dict[str, dict]:
collector = EastMoneyDiscoveryCollector(
proxy=_resolve_market_scan_proxy(),
)
@@ -981,7 +983,7 @@ def _load_market_scan_inputs(limit_per_market: int = 60) -> dict[str, dict]:
safe_limit = max(20, int(limit_per_market))
min_required = min(max(12, int(safe_limit * 0.55)), safe_limit)
- for market in ("CN", "HK", "US"):
+ for market in (("CN", "HK", "US") if markets is None else markets):
try:
turnover = _run_async(
collector.fetch_hot_stocks(
@@ -1102,7 +1104,7 @@ def _load_market_scan_inputs(limit_per_market: int = 60) -> dict[str, dict]:
)
# Final per-market cap and stable ordering.
- for market in ("CN", "HK", "US"):
+ for market in (("CN", "HK", "US") if markets is None else markets):
keys = [k for k in result.keys() if k.startswith(f"{market}:")]
if len(keys) <= safe_limit:
continue
@@ -1115,13 +1117,14 @@ def _load_market_scan_inputs(limit_per_market: int = 60) -> dict[str, dict]:
return result
-def _persist_market_scan_snapshot(snapshot: str, market_scan_map: dict[str, dict]) -> None:
+def _persist_market_scan_snapshot(snapshot: str, market_scan_map: dict[str, dict], *, markets: list[str] | None = None) -> None:
if not snapshot:
return
db = SessionLocal()
try:
db.query(MarketScanSnapshot).filter(
- MarketScanSnapshot.snapshot_date == snapshot
+ MarketScanSnapshot.snapshot_date == snapshot,
+ MarketScanSnapshot.stock_market.in_(markets) if markets is not None else True,
).delete(synchronize_session=False)
rows = sorted(
market_scan_map.values(),
@@ -1254,11 +1257,17 @@ def refresh_entry_candidates(
snapshot_date: str | None = None,
market_scan_limit: int = 60,
max_kline_symbols: int = 72,
+ markets: list[str] | None = None,
) -> dict:
snapshot = (snapshot_date or date.today().strftime("%Y-%m-%d")).strip()
+ if markets == []:
+ return {"snapshot_date": snapshot, "count": 0, "items": []}
suggestions = _load_latest_suggestions(limit=max_inputs)
- market_scan_map = _load_market_scan_inputs(limit_per_market=max(20, int(market_scan_limit)))
- _persist_market_scan_snapshot(snapshot, market_scan_map)
+ if markets is not None:
+ suggestions = [s for s in suggestions if s.stock_market in markets]
+ scan_kwargs = {"markets": markets} if markets is not None else {}
+ market_scan_map = _load_market_scan_inputs(limit_per_market=max(20, int(market_scan_limit)), **scan_kwargs)
+ _persist_market_scan_snapshot(snapshot, market_scan_map, **scan_kwargs)
holding_keys = _load_holding_keys()
input_map: dict[str, dict] = dict(market_scan_map)
@@ -1376,7 +1385,8 @@ def refresh_entry_candidates(
items: list[dict] = []
try:
db.query(EntryCandidate).filter(
- EntryCandidate.snapshot_date == snapshot
+ EntryCandidate.snapshot_date == snapshot,
+ EntryCandidate.stock_market.in_(markets) if markets is not None else True,
).delete(synchronize_session=False)
for key, inp in input_map.items():
@@ -1732,11 +1742,12 @@ def evaluate_entry_candidate_outcomes(
stats["skipped_not_due"] += 1
continue
- stats["eligible"] += 1
- outcome_price = _pick_close_on_or_before(klines, target_day)
- if outcome_price is None:
- stats["skipped_no_price"] += 1
+ bar = completed_outcome_bar(klines, snap_day, horizon, c.stock_market)
+ if bar is None:
+ stats["skipped_not_due"] += 1
continue
+ target_day, outcome_price = bar
+ stats["eligible"] += 1
base_price = None
if c.entry_low is not None and c.entry_high is not None:
@@ -1792,6 +1803,7 @@ def evaluate_entry_candidate_outcomes(
outcome_status=status,
meta=to_jsonable(
{
+ "horizon_unit": "trading_days",
"candidate_score": float(c.score or 0),
"action": c.action or "",
"action_label": c.action_label or "",
diff --git a/src/modules/strategy/factor_eval.py b/src/modules/strategy/factor_eval.py
index c8cc0fe73..94762650f 100644
--- a/src/modules/strategy/factor_eval.py
+++ b/src/modules/strategy/factor_eval.py
@@ -95,6 +95,7 @@ def evaluate_factor_ic(
)
.filter(
StrategyOutcome.horizon_days == int(horizon),
+ StrategyOutcome.meta["horizon_unit"].as_string() == "trading_days",
StrategyOutcome.outcome_status.in_(("evaluated", "hit_target", "hit_stop")),
StrategyOutcome.outcome_return_pct.isnot(None),
StrategyFactorSnapshot.snapshot_date >= cutoff,
diff --git a/src/modules/strategy/strategy_engine.py b/src/modules/strategy/strategy_engine.py
index 2a69d9d06..efef8132e 100644
--- a/src/modules/strategy/strategy_engine.py
+++ b/src/modules/strategy/strategy_engine.py
@@ -2,6 +2,8 @@
from __future__ import annotations
+from src.platform.marketdata.outcome_prices import completed_outcome_bar
+
import logging
from datetime import date, datetime, timedelta
from math import sqrt
@@ -932,6 +934,7 @@ def _sync_factor_and_risk_snapshots(
db,
snapshot: str,
signals: list[StrategySignalRun],
+ markets: list[str] | None = None,
) -> None:
run_ids = [int(s.id) for s in signals if s.id is not None]
if not run_ids:
@@ -939,7 +942,8 @@ def _sync_factor_and_risk_snapshots(
existing_factors = (
db.query(StrategyFactorSnapshot)
- .filter(StrategyFactorSnapshot.snapshot_date == snapshot)
+ .filter(StrategyFactorSnapshot.snapshot_date == snapshot,
+ StrategyFactorSnapshot.stock_market.in_(markets) if markets is not None else True)
.all()
)
factor_map = {int(x.signal_run_id): x for x in existing_factors}
@@ -1201,7 +1205,10 @@ def refresh_strategy_signals(
market_scan_limit: int = 80,
max_kline_symbols: int = 72,
limit_candidates: int = 2000,
+ markets: list[str] | None = None,
) -> dict:
+ if markets == []:
+ return {"snapshot_date": snapshot_date, "count": 0, "items": []}
ensure_strategy_catalog()
if rebuild_candidates:
refresh_entry_candidates(
@@ -1209,6 +1216,7 @@ def refresh_strategy_signals(
snapshot_date=snapshot_date or None,
market_scan_limit=market_scan_limit,
max_kline_symbols=max_kline_symbols,
+ **({"markets": markets} if markets is not None else {}),
)
db = SessionLocal()
@@ -1226,7 +1234,8 @@ def refresh_strategy_signals(
candidates = (
db.query(EntryCandidate)
- .filter(EntryCandidate.snapshot_date == snapshot)
+ .filter(EntryCandidate.snapshot_date == snapshot,
+ EntryCandidate.stock_market.in_(markets) if markets is not None else True)
.order_by(EntryCandidate.score.desc(), EntryCandidate.updated_at.desc())
.limit(max(20, int(limit_candidates)))
.all()
@@ -1249,7 +1258,8 @@ def refresh_strategy_signals(
)
existing_rows = (
db.query(StrategySignalRun)
- .filter(StrategySignalRun.snapshot_date == snapshot)
+ .filter(StrategySignalRun.snapshot_date == snapshot,
+ StrategySignalRun.stock_market.in_(markets) if markets is not None else True)
.all()
)
existing: dict[tuple[int, str], StrategySignalRun] = {}
@@ -1405,7 +1415,8 @@ def refresh_strategy_signals(
rows = (
db.query(StrategySignalRun)
- .filter(StrategySignalRun.snapshot_date == snapshot)
+ .filter(StrategySignalRun.snapshot_date == snapshot,
+ StrategySignalRun.stock_market.in_(markets) if markets is not None else True)
.order_by(StrategySignalRun.rank_score.desc(), StrategySignalRun.updated_at.desc())
.all()
)
@@ -1413,6 +1424,7 @@ def refresh_strategy_signals(
db=db,
snapshot=snapshot,
signals=rows,
+ markets=markets,
)
db.commit()
factor_map: dict[int, StrategyFactorSnapshot] = {}
@@ -1638,12 +1650,12 @@ def evaluate_strategy_outcomes(
klines = kline_cache[key]
for horizon in pending_horizons:
- target_day = snap_day + timedelta(days=horizon)
- stats["eligible"] += 1
- outcome_price = _pick_close_on_or_before(klines, target_day)
- if outcome_price is None:
- stats["skipped_no_price"] += 1
+ bar = completed_outcome_bar(klines, snap_day, horizon, s.stock_market)
+ if bar is None:
+ stats["skipped_not_due"] += 1
continue
+ target_day, outcome_price = bar
+ stats["eligible"] += 1
base_price = None
if s.entry_low is not None and s.entry_high is not None:
base_price = (float(s.entry_low) + float(s.entry_high)) / 2
@@ -1698,7 +1710,8 @@ def evaluate_strategy_outcomes(
outcome_status=status,
meta=to_jsonable(
{
- "rank_score": float(s.rank_score or 0),
+ "horizon_unit": "trading_days",
+ "rank_score": float(s.rank_score or 0),
"action": s.action or "",
"action_label": s.action_label or "",
}
diff --git a/src/platform/marketdata/models.py b/src/platform/marketdata/models.py
index 2c5e6d61b..6a14fdf9e 100644
--- a/src/platform/marketdata/models.py
+++ b/src/platform/marketdata/models.py
@@ -31,15 +31,7 @@ def get_tz(self) -> ZoneInfo:
def is_trading_time(self, dt: datetime | None = None) -> bool:
"""判断给定时间是否在交易时段内"""
- if dt is None:
- dt = datetime.now(self.get_tz())
- else:
- dt = dt.astimezone(self.get_tz())
-
- # 非交易日(周末 / A股法定节假日)一律不交易。
- # 延迟导入:trading_calendar 依赖本模块的 MarketCode/MARKETS。
from src.platform.scheduling.trading_calendar import market_status
-
return market_status(self.code, dt) == "trading"
diff --git a/src/platform/marketdata/outcome_prices.py b/src/platform/marketdata/outcome_prices.py
new file mode 100644
index 000000000..1f3ef4b87
--- /dev/null
+++ b/src/platform/marketdata/outcome_prices.py
@@ -0,0 +1,31 @@
+"""Forward outcomes count actual, completed daily bars rather than calendar days."""
+from datetime import date
+from math import isfinite
+
+from src.platform.scheduling import trading_calendar as calendar
+
+
+def completed_outcome_bar(klines, base_day: date, horizon: int, market) -> tuple[date, float] | None:
+ code = calendar._to_market_code(market)
+ if code is None:
+ return None
+ now = calendar._now_in_market_tz(code)
+ sessions = calendar.trading_sessions(code, now)
+ today_complete = bool(sessions and now.time() > sessions[-1].end)
+ rows = {}
+ for bar in klines or []:
+ try:
+ day = date.fromisoformat(str(bar.date)[:10].replace("/", "-"))
+ close = float(bar.close)
+ except (AttributeError, TypeError, ValueError):
+ continue
+ if day <= base_day or day > now.date() or (day == now.date() and not today_complete):
+ continue
+ if not isfinite(close) or close <= 0:
+ continue
+ if calendar.calendar_known(code, day) and not calendar.is_trading_day(code, day):
+ continue
+ rows[day] = close
+ future = sorted(rows.items())
+ index = max(1, int(horizon)) - 1
+ return future[index] if index < len(future) else None
diff --git a/src/platform/marketdata/quote_display.py b/src/platform/marketdata/quote_display.py
index 044fcf350..570f0ae88 100644
--- a/src/platform/marketdata/quote_display.py
+++ b/src/platform/marketdata/quote_display.py
@@ -25,3 +25,12 @@ def daily_quote_fields(market: str, quote: dict | None, now: datetime | None = N
"daily_move_status": daily_status,
"quote_date": quote_date,
}
+
+
+def quote_date_is_current(market: str, quote: dict) -> bool:
+ """Reject a known stale date; providers without quote dates retain compatibility."""
+ quote_date = quote.get("quote_date")
+ if not quote_date:
+ return True
+ code = calendar._to_market_code(market)
+ return code is not None and str(quote_date)[:10] == calendar._now_in_market_tz(code).date().isoformat()
diff --git a/src/platform/scheduling/schedule_parser.py b/src/platform/scheduling/schedule_parser.py
index b3af4ce0f..3a4eb67c3 100644
--- a/src/platform/scheduling/schedule_parser.py
+++ b/src/platform/scheduling/schedule_parser.py
@@ -13,7 +13,7 @@
from __future__ import annotations
import re
-from datetime import datetime
+from datetime import datetime, timedelta
from typing import Iterable
from zoneinfo import ZoneInfo
@@ -173,6 +173,9 @@ def preview_schedule(
count: int = 5,
timezone: str = "UTC",
start: datetime | None = None,
+ markets: Iterable | None = None,
+ trading_hours_only: bool = False,
+ end: datetime | None = None,
) -> list[datetime]:
"""Return next N run times for a schedule.
@@ -186,14 +189,30 @@ def preview_schedule(
tz = ZoneInfo(timezone)
now = start.astimezone(tz) if start else datetime.now(tz)
+ from src.platform.scheduling.trading_calendar import eligible_markets, next_eligible_time
+
+ target_markets = list(markets) if markets is not None else None
+ if target_markets == []:
+ return []
out: list[datetime] = []
prev = None
current = now
- for _ in range(count):
+ limit = end or now + timedelta(days=370)
+ for _ in range(max(1000, count * 10)):
nxt = trigger.get_next_fire_time(prev, current)
- if not nxt:
+ if not nxt or nxt > limit:
break
+ if target_markets is not None and not eligible_markets(target_markets, nxt, trading_hours_only=trading_hours_only):
+ boundaries = [boundary for market in target_markets
+ if (boundary := next_eligible_time(market, nxt, trading_hours_only=trading_hours_only)) is not None]
+ if not boundaries:
+ break
+ current = min(boundaries).astimezone(tz)
+ prev = None
+ continue
out.append(nxt)
+ if len(out) >= count:
+ break
prev = nxt
current = nxt
return out
@@ -206,23 +225,13 @@ def count_runs_within(
end: datetime,
timezone: str = "UTC",
max_iters: int = 20000,
+ markets: Iterable | None = None,
+ trading_hours_only: bool = False,
) -> int:
"""Count fire times within (start, end]."""
if not schedule or end <= start:
return 0
- trigger = parse_schedule(schedule, timezone=timezone)
-
- count = 0
- prev = None
- current = start
- for _ in range(max_iters):
- nxt = trigger.get_next_fire_time(prev, current)
- if not nxt:
- break
- if nxt > end:
- break
- count += 1
- prev = nxt
- current = nxt
- return count
+ return len(preview_schedule(schedule, count=max_iters, timezone=timezone,
+ start=start + timedelta(microseconds=1), end=end,
+ markets=markets, trading_hours_only=trading_hours_only))
diff --git a/src/platform/scheduling/trading_calendar.py b/src/platform/scheduling/trading_calendar.py
index 4b40d55e5..3ef198c27 100644
--- a/src/platform/scheduling/trading_calendar.py
+++ b/src/platform/scheduling/trading_calendar.py
@@ -1,96 +1,33 @@
-"""交易日历:回答「这一天开不开市」。
+"""Bounded exchange calendars shared by display and automatic execution.
-与 `MarketDef.is_trading_time()`(回答「当下是否在交易时段内」)互补 ——
-盘前计划、日终摘要这类定时任务本身就发生在交易时段之外,只能用「是不是交易日」
-来守卫,用时段判断会把它们永久拦死。
-
-数据源
-- **A 股**:akshare 交易日历(`tool_trade_date_hist_sina`),含法定节假日,权威。
- 结果缓存在内存,由 `refresh()` 更新(启动预热 + 每日凌晨刷新)。
-- **离线兜底**:内置交易所公布的 2026 年 CN/HK/US 休市和半日市安排。
-
-降级原则
-拿不到在线日历时优先使用内置年度日历;未覆盖年份退回「只判周末」。
-少发一条是遗憾,漏发一整天是事故。
-
-并发安全
-同步接口只读内存缓存,**永不发起网络请求**;网络拉取集中在 `refresh()`
-(内部 `asyncio.to_thread`)和 `refresh_blocking()`,避免阻塞事件循环。
+Published annual exchange data is bundled with the application. Runtime warmup
+only materializes the recent window (30 days back, 90 days ahead), without any
+network requests or full-history decoding. Unpublished weekdays are unknown and
+cannot authorize automatic work. Historical K-line queries remain independent.
"""
-
from __future__ import annotations
-import asyncio
import logging
-from datetime import date, datetime
+import time as clock
+from datetime import date, datetime, time, timedelta, timezone
from zoneinfo import ZoneInfo
from src.platform.scheduling.exchange_calendar_data import HOLIDAYS, EARLY_CLOSES
logger = logging.getLogger(__name__)
-
-# A 股交易日集合;None = 尚未加载或加载失败(此时使用内置年度日历兜底)
-_CN_TRADING_DATES: frozenset[date] | None = None
-# 日历覆盖区间,用于判断查询日期是否落在可信范围内(跨年未刷新时会超出)
-_CN_RANGE: tuple[date, date] | None = None
-
+_RECENT_OPEN_DAYS: dict[str, frozenset[date]] = {}
+_WARMED_ON: date | None = None
_FALLBACK_TZ = "Asia/Shanghai"
def reset_cache() -> None:
- """清空日历缓存(配置变更或测试用)。"""
- global _CN_TRADING_DATES, _CN_RANGE
- _CN_TRADING_DATES = None
- _CN_RANGE = None
-
-
-def _fetch_cn_trading_dates() -> frozenset[date]:
- """阻塞拉取 A 股交易日历。仅由 `refresh_blocking()` 调用。"""
- import akshare as ak
-
- df = ak.tool_trade_date_hist_sina()
- out: set[date] = set()
- for raw in df["trade_date"]:
- if isinstance(raw, datetime):
- out.add(raw.date())
- elif isinstance(raw, date):
- out.add(raw)
- else:
- out.add(date.fromisoformat(str(raw)[:10]))
- return frozenset(out)
-
-
-def refresh_blocking() -> bool:
- """同步刷新 A 股交易日历。返回是否成功;失败不抛异常(保持降级行为)。"""
- global _CN_TRADING_DATES, _CN_RANGE
- try:
- dates = _fetch_cn_trading_dates()
- except Exception as e:
- logger.warning("[交易日历] A股日历拉取失败,保留缓存并使用内置日历兜底: %s", e)
- return False
- if not dates:
- logger.warning("[交易日历] A股日历为空,保留缓存并使用内置日历兜底")
- return False
- _CN_TRADING_DATES = dates
- _CN_RANGE = (min(dates), max(dates))
- logger.info(
- "[交易日历] A股日历已加载: %s 个交易日 (%s ~ %s)",
- len(dates),
- _CN_RANGE[0],
- _CN_RANGE[1],
- )
- return True
-
-
-async def refresh() -> bool:
- """异步刷新日历(走线程池,不阻塞事件循环)。"""
- return await asyncio.to_thread(refresh_blocking)
+ global _WARMED_ON
+ _RECENT_OPEN_DAYS.clear()
+ _WARMED_ON = None
def _to_market_code(market):
- """把 MarketCode / 字符串归一化为 MarketCode;无法识别返回 None。"""
from src.platform.marketdata.models import MarketCode
-
if isinstance(market, MarketCode):
return market
try:
@@ -101,63 +38,60 @@ def _to_market_code(market):
def _market_tz(code) -> ZoneInfo:
from src.platform.marketdata.models import MARKETS
-
- md = MARKETS.get(code) if code else None
- return md.get_tz() if md else ZoneInfo(_FALLBACK_TZ)
+ definition = MARKETS.get(code)
+ return definition.get_tz() if definition else ZoneInfo(_FALLBACK_TZ)
def _now_in_market_tz(code) -> datetime:
- """该市场时区的当前时间。独立成函数便于测试注入。"""
return datetime.now(_market_tz(code))
def _resolve_date(code, d: date | datetime | None) -> date:
- """把入参归一化为「该市场当地日期」。"""
if d is None:
return _now_in_market_tz(code).date()
if isinstance(d, datetime):
- if d.tzinfo is not None:
- d = d.astimezone(_market_tz(code))
- return d.date()
+ return d.astimezone(_market_tz(code)).date() if d.tzinfo else d.date()
return d
-def is_trading_day(market, d: date | datetime | None = None) -> bool:
- """给定市场的某一天是否开市。
+def calendar_known(market, d: date | datetime | None = None) -> bool:
+ code = _to_market_code(market)
+ target = _resolve_date(code, d)
+ return code is not None and (target.weekday() >= 5 or (code.value, target.year) in HOLIDAYS)
- Args:
- market: `MarketCode` 或市场码字符串(CN/HK/US)。
- d: 目标日期;`None` 表示该市场时区的今天。带时区的 `datetime`
- 会先换算到市场时区再取日期。
- """
- from src.platform.marketdata.models import MarketCode
+def is_trading_day(market, d: date | datetime | None = None) -> bool:
code = _to_market_code(market)
target = _resolve_date(code, d)
-
- # 周末:三个市场都不开。零依赖、永远准确,放在最前面。
- if target.weekday() >= 5:
+ if code is None or target.weekday() >= 5:
return False
+ holidays = HOLIDAYS.get((code.value, target.year))
+ return holidays is not None and target not in holidays
- # A 股:日历已加载且覆盖该日期时按日历判(含法定节假日)。
- if code == MarketCode.CN and _CN_TRADING_DATES and _CN_RANGE:
- if _CN_RANGE[0] <= target <= _CN_RANGE[1]:
- return target in _CN_TRADING_DATES
- logger.debug("[交易日历] %s 超出A股在线日历覆盖范围,尝试内置年度日历", target)
- # 港美股或在线日历未覆盖:使用交易所公布的年度休市安排。
- holidays = HOLIDAYS.get((code.value, target.year)) if code else None
- if holidays is not None:
- return target not in holidays
+def refresh_blocking() -> bool:
+ """Warm a bounded local window; never contact a calendar data provider."""
+ global _WARMED_ON
+ today = _now_in_market_tz(_to_market_code("CN")).date()
+ if _WARMED_ON == today:
+ return bool(_RECENT_OPEN_DAYS)
+ started = clock.monotonic()
+ start, end = today - timedelta(days=30), today + timedelta(days=90)
+ window = [start + timedelta(days=n) for n in range((end - start).days + 1)]
+ for code in ("CN", "HK", "US"):
+ _RECENT_OPEN_DAYS[code] = frozenset(day for day in window if is_trading_day(code, day))
+ _WARMED_ON = today
+ logger.info("[交易日历] 本地近期日历已加载: %s ~ %s, %.1f ms (无需网络)",
+ start, end, (clock.monotonic() - started) * 1000)
+ return any(_RECENT_OPEN_DAYS.values())
- # 未覆盖年份:只判周末。
- return True
+
+async def refresh() -> bool:
+ return refresh_blocking()
def trading_sessions(market, d: date | datetime | None = None) -> list:
- """该市场当天的常规交易时段,包含半日市;休市日返回空列表。"""
from src.platform.marketdata.models import MARKETS, TradingSession
-
code = _to_market_code(market)
target = _resolve_date(code, d)
if code not in MARKETS or not is_trading_day(code, target):
@@ -170,12 +104,13 @@ def trading_sessions(market, d: date | datetime | None = None) -> list:
def market_status(market, dt: datetime | None = None) -> str:
- """按当地交易日和当日时段统一判断状态,请求路径只读内存。"""
code = _to_market_code(market)
if code is None:
return "unknown"
now = dt.astimezone(_market_tz(code)) if dt is not None else _now_in_market_tz(code)
- sessions = trading_sessions(code, now.date())
+ if not calendar_known(code, now):
+ return "unknown"
+ sessions = trading_sessions(code, now)
if not sessions:
return "closed"
current = now.time()
@@ -188,10 +123,72 @@ def market_status(market, dt: datetime | None = None) -> str:
return "break"
+def eligible_markets(markets=None, dt: datetime | None = None, *, trading_hours_only: bool = False) -> list[str]:
+ from src.platform.marketdata.models import MARKETS
+ return [code.value for raw in (MARKETS if markets is None else markets)
+ if (code := _to_market_code(raw)) is not None
+ and (market_status(code, dt) == "trading" if trading_hours_only else is_trading_day(code, dt))]
+
+
def any_market_trading_day(d: date | datetime | None = None) -> bool:
- """CN/HK/US 任一为交易日即 `True`。全市场休市(如周末)返回 `False`。"""
- from src.platform.marketdata.models import MarketCode
+ from src.platform.marketdata.models import MARKETS
+ return any(is_trading_day(code, d) for code in MARKETS)
- return any(
- is_trading_day(m, d) for m in (MarketCode.CN, MarketCode.HK, MarketCode.US)
- )
+
+def next_eligible_time(market, dt: datetime, *, trading_hours_only: bool = False) -> datetime | None:
+ """Find a confirmed boundary, allowing preview to jump over closed periods."""
+ code = _to_market_code(market)
+ if code is None:
+ return None
+ local = dt.astimezone(_market_tz(code))
+ for offset in range(370):
+ day = local.date() + timedelta(days=offset)
+ if not is_trading_day(code, day):
+ continue
+ earliest = local if offset == 0 else datetime.combine(day, time.min, _market_tz(code))
+ if not trading_hours_only:
+ return earliest
+ for session in trading_sessions(code, day):
+ opening = datetime.combine(day, session.start, _market_tz(code))
+ closing = datetime.combine(day, session.end, _market_tz(code))
+ if earliest <= closing:
+ return max(earliest, opening)
+ return None
+
+
+def local_day_bounds(market, d: date | datetime | None = None) -> tuple[datetime, datetime]:
+ code = _to_market_code(market)
+ day = _resolve_date(code, d)
+ tz = _market_tz(code)
+ return (datetime.combine(day, time.min, tz).astimezone(timezone.utc),
+ datetime.combine(day + timedelta(days=1), time.min, tz).astimezone(timezone.utc))
+
+
+def upcoming_calendar(market, *, days: int = 14, start_date: date | None = None) -> dict:
+ code = _to_market_code(market)
+ now = _now_in_market_tz(code)
+ rows = []
+ for offset in range(days):
+ day = (start_date or now.date()) + timedelta(days=offset)
+ known = calendar_known(code, day)
+ opened = is_trading_day(code, day)
+ reason = ("unpublished" if not known else "weekend" if day.weekday() >= 5
+ else "holiday" if not opened else "early_close" if (code.value, day) in EARLY_CLOSES
+ else "trading")
+ rows.append({"date": day.isoformat(), "is_trading_day": opened if known else None,
+ "reason": reason, "sessions": [f"{s.start:%H:%M}-{s.end:%H:%M}" for s in trading_sessions(code, day)],
+ "session_times": [{"open": datetime.combine(day, s.start, _market_tz(code)).isoformat(),
+ "close": datetime.combine(day, s.end, _market_tz(code)).isoformat()}
+ for s in trading_sessions(code, day)]})
+ next_open = None
+ for offset in range(370):
+ day = now.date() + timedelta(days=offset)
+ openings = [datetime.combine(day, session.start, _market_tz(code))
+ for session in trading_sessions(code, day)]
+ next_open = next((opening for opening in openings if opening > now), None)
+ if next_open is not None:
+ break
+ return {"market": code.value, "timezone": str(_market_tz(code)),
+ "local_date": now.date().isoformat(), "local_time": now.strftime("%H:%M"), "status": market_status(code, now), "source": "exchange",
+ "covered_years": sorted(year for (m, year) in HOLIDAYS if m == code.value),
+ "next_open": next_open.isoformat() if next_open else None, "days": rows}
diff --git a/tests/test_agent_scheduler.py b/tests/test_agent_scheduler.py
index 6204a84d1..1865c7883 100644
--- a/tests/test_agent_scheduler.py
+++ b/tests/test_agent_scheduler.py
@@ -6,7 +6,7 @@
import pytest
-from src.modules.automation import agent_scheduler
+from src.modules.automation import agent_scheduler, scheduling_policy
from src.platform.marketdata.models import MarketCode
@@ -25,9 +25,9 @@ def scheduler_fixture(monkeypatch, *, mode="single", markets=(), result=None):
scheduler.agents["intraday_monitor"] = agent
scheduler.execution_modes["intraday_monitor"] = mode
scheduler.set_context_builder(lambda name: context)
- monkeypatch.setattr(agent_scheduler, "MARKETS", {
- MarketCode.CN: SimpleNamespace(name="CN", is_trading_time=lambda: False),
- MarketCode.HK: SimpleNamespace(name="HK", is_trading_time=lambda: True),
+ monkeypatch.setattr(scheduling_policy, "MARKETS", {
+ MarketCode.CN: SimpleNamespace(name="CN", is_trading_time=lambda dt=None: False),
+ MarketCode.HK: SimpleNamespace(name="HK", is_trading_time=lambda dt=None: True),
})
record = Mock()
monkeypatch.setattr(agent_scheduler, "record_agent_run", record)
diff --git a/tests/test_calendar_notifications.py b/tests/test_calendar_notifications.py
new file mode 100644
index 000000000..498e78af9
--- /dev/null
+++ b/tests/test_calendar_notifications.py
@@ -0,0 +1,37 @@
+"""Market-local paper notifications must not include holiday markets/trades."""
+import asyncio
+from datetime import datetime
+from unittest.mock import AsyncMock
+from zoneinfo import ZoneInfo
+
+from src.platform.persistence.models import PaperTradingAccount, PaperTradingPosition, PaperTradingTrade
+from tests.test_trading_execution_guards import memory_db, freeze
+
+
+def test_us_summary_uses_new_york_trade_date_with_both_utc_bounds(monkeypatch, memory_db):
+ from src.modules.paper_trading import paper_trading_notifier as notifier
+ instant = datetime(2026, 10, 2, 4, 30, tzinfo=ZoneInfo("Asia/Shanghai"))
+ freeze(monkeypatch, instant)
+ monkeypatch.setattr(notifier, "SessionLocal", memory_db)
+ monkeypatch.setattr(notifier, "_is_mode_enabled", lambda *_args: True)
+ monkeypatch.setattr(notifier, "_report_is_english", lambda: True)
+ manager = type("Notifier", (), {"notify": AsyncMock()})()
+ monkeypatch.setattr(notifier, "_build_notifier", lambda: manager)
+ with memory_db() as db:
+ db.add(PaperTradingAccount(enabled=True, initial_capital=100_000, current_capital=100_000,
+ market_allocations={"CN": .5, "US": .5}))
+ for symbol, market, closed in [
+ ("VALID", "US", datetime(2026, 10, 2, 0, 10)),
+ ("EARLY", "US", datetime(2026, 10, 1, 3, 59)),
+ ("FUTURE", "US", datetime(2026, 10, 2, 4, 1)),
+ ("CLOSED_CN", "CN", datetime(2026, 10, 1, 10)),
+ ]:
+ db.add(PaperTradingTrade(stock_symbol=symbol, stock_market=market, stock_name=symbol,
+ quantity=100, entry_price=10, exit_price=11, pnl=100, pnl_pct=10, closed_at=closed))
+ db.commit()
+ asyncio.run(notifier.send_daily_summary(markets=["CN", "US"]))
+ manager.notify.assert_awaited_once()
+ title, body = manager.notify.call_args.args
+ assert "US" in title and "2026-10-01" in title
+ assert "VALID" in body
+ assert all(symbol not in body for symbol in ("EARLY", "FUTURE", "CLOSED_CN"))
diff --git a/tests/test_factor_calibration.py b/tests/test_factor_calibration.py
index 77aa6ea83..1e0a53eb0 100644
--- a/tests/test_factor_calibration.py
+++ b/tests/test_factor_calibration.py
@@ -96,6 +96,7 @@ def _seed_pair(db, sid, *, market, snapshot_date, alpha=0.0, ret=0.0,
db.add(StrategyOutcome(
signal_run_id=sid, strategy_code="trend_follow", stock_symbol=f"S{sid}",
stock_market=market, snapshot_date=snapshot_date, horizon_days=horizon,
+ meta={"horizon_unit": "trading_days"},
target_date=snapshot_date, outcome_return_pct=ret, outcome_status=status,
))
diff --git a/tests/test_factor_calibration_loop.py b/tests/test_factor_calibration_loop.py
index 39b719aa8..2849ae1c1 100644
--- a/tests/test_factor_calibration_loop.py
+++ b/tests/test_factor_calibration_loop.py
@@ -28,6 +28,7 @@ def _seed_pair(db, sid, *, market, snapshot_date, alpha, ret, horizon=5):
db.add(StrategyOutcome(
signal_run_id=sid, strategy_code="trend_follow", stock_symbol=f"S{sid}",
stock_market=market, snapshot_date=snapshot_date, horizon_days=horizon,
+ meta={"horizon_unit": "trading_days"},
target_date=snapshot_date, outcome_return_pct=ret, outcome_status="evaluated",
))
diff --git a/tests/test_notifications.py b/tests/test_notifications.py
index 7a50d6d24..ab959cfa5 100644
--- a/tests/test_notifications.py
+++ b/tests/test_notifications.py
@@ -191,6 +191,7 @@ def test_price_hit_survives_delivery_failure_and_repeated_scan_deduplicates(db,
db.add(rule); db.commit()
monkeypatch.setattr(price_alert_engine, 'SessionLocal', sessionmaker(bind=db.bind))
engine = price_alert_engine.PriceAlertEngine()
+ monkeypatch.setattr('src.platform.scheduling.trading_calendar.is_trading_day', lambda *args: True)
monkeypatch.setattr(engine, '_fetch_quotes_map', AsyncMock(return_value={('CN','601238'):{'current_price':6}}))
async def delivery(session, *args):
# The same transaction already committed both records before I/O.
@@ -267,7 +268,7 @@ def test_scheduler_delivery_failure_does_not_mark_generated_report_failed(monkey
scheduler.agents['daily_report'] = agent
scheduler.execution_modes['daily_report'] = mode
scheduler.set_context_builder(lambda name: context)
- monkeypatch.setattr(agent_scheduler, 'MARKETS', {MarketCode.US:SimpleNamespace(is_trading_time=lambda:True)})
+ monkeypatch.setattr(agent_scheduler, 'market_allowed', lambda *args: True)
record = Mock(); monkeypatch.setattr(agent_scheduler, 'record_agent_run', record)
asyncio.run(scheduler._run_agent('daily_report'))
values=record.call_args.kwargs
diff --git a/tests/test_trading_calendar.py b/tests/test_trading_calendar.py
index 6f3d204a4..b30dd025a 100644
--- a/tests/test_trading_calendar.py
+++ b/tests/test_trading_calendar.py
@@ -11,41 +11,15 @@
from src.platform.scheduling import trading_calendar as tc
from src.platform.marketdata.models import MARKETS, MarketCode
-# 2026 年真实日历切片:8/8 周六、8/9 周日休市;8/10 周一开市;
-# 10/1~10/7 国庆休市(其中 10/1 是周四 —— 工作日却休市,只靠周末判断抓不到)。
-_FAKE_CN_DATES = frozenset(
- {
- date(2026, 8, 3),
- date(2026, 8, 4),
- date(2026, 8, 5),
- date(2026, 8, 6),
- date(2026, 8, 7),
- date(2026, 8, 10),
- date(2026, 8, 11),
- date(2026, 8, 12),
- date(2026, 8, 13),
- date(2026, 8, 14),
- date(2026, 9, 28),
- date(2026, 9, 29),
- date(2026, 9, 30),
- date(2026, 10, 8),
- date(2026, 10, 9),
- }
-)
-
-
@pytest.fixture(autouse=True)
def _reset_calendar():
- """每个用例前后清空日历缓存,避免互相污染。"""
tc.reset_cache()
yield
tc.reset_cache()
@pytest.fixture
-def loaded_calendar(monkeypatch):
- """注入固定 A 股交易日历(不走网络)。"""
- monkeypatch.setattr(tc, "_fetch_cn_trading_dates", lambda: _FAKE_CN_DATES)
+def loaded_calendar():
assert tc.refresh_blocking() is True
@@ -76,14 +50,15 @@ def test_法定节假日不是交易日(loaded_calendar):
def test_日历缺失时使用内置年度日历():
"""在线日历尚未加载时仍能正确识别已公布的休市日。"""
- assert tc._CN_TRADING_DATES is None
+ assert tc._RECENT_OPEN_DAYS == {}
assert tc.is_trading_day(MarketCode.CN, date(2026, 10, 1)) is False # 离线兜底识别国庆
assert tc.is_trading_day(MarketCode.CN, date(2026, 8, 8)) is False # 但周末照样拦住
-def test_超出日历覆盖范围时降级为只判周末(loaded_calendar):
- """查询日期超出日历区间(如跨年未刷新)时降级,不误判交易日为休市。"""
- assert tc.is_trading_day(MarketCode.CN, date(2027, 3, 1)) is True # 2027-03-01 是周一
+def test_未公布年度不能授权自动执行(loaded_calendar):
+ """未覆盖的工作日明确为未知,不会猜测开市。"""
+ assert tc.is_trading_day(MarketCode.CN, date(2027, 3, 1)) is False
+ assert tc.calendar_known("CN", date(2027, 3, 1)) is False # 2027-03-01 是周一
def test_各市场使用独立休市日历(loaded_calendar):
@@ -109,23 +84,14 @@ def test_any_market_trading_day(loaded_calendar):
assert tc.any_market_trading_day(date(2026, 10, 1)) is True
-def test_刷新失败不抛异常且保持降级(monkeypatch):
- """日历拉取抛异常时 refresh 返回 False,缓存保持空,行为降级而非崩溃。"""
-
- def _boom():
- raise RuntimeError("network down")
-
- monkeypatch.setattr(tc, "_fetch_cn_trading_dates", _boom)
- assert tc.refresh_blocking() is False
- assert tc._CN_TRADING_DATES is None
- assert tc.is_trading_day(MarketCode.CN, date(2026, 8, 10)) is True
-
-
-def test_异步刷新不阻塞(monkeypatch):
- """refresh() 走 to_thread,结果与同步版一致。"""
- monkeypatch.setattr(tc, "_fetch_cn_trading_dates", lambda: _FAKE_CN_DATES)
+def test_近期预热不请求网络且范围有限(monkeypatch):
+ from unittest.mock import Mock
+ import requests
+ network = Mock(side_effect=AssertionError("calendar warmup must not fetch history"))
+ monkeypatch.setattr(requests, "get", network)
assert asyncio.run(tc.refresh()) is True
- assert tc.is_trading_day(MarketCode.CN, date(2026, 10, 1)) is False
+ assert all(len(days) <= 121 for days in tc._RECENT_OPEN_DAYS.values())
+ network.assert_not_called()
# ---------------------------------------------------------------------------
@@ -163,10 +129,10 @@ def _patch_notifiers(monkeypatch) -> dict[str, int]:
"""把两个通知函数替换成计数器,用于断言是否被调用。"""
calls = {"premarket": 0, "summary": 0}
- async def _fake_premarket():
+ async def _fake_premarket(**kwargs):
calls["premarket"] += 1
- async def _fake_summary():
+ async def _fake_summary(**kwargs):
calls["summary"] += 1
monkeypatch.setattr(
@@ -183,8 +149,8 @@ def test_周末不发盘前计划和日终摘要(monkeypatch):
from src.modules.paper_trading.paper_trading_scheduler import PaperTradingScheduler
calls = _patch_notifiers(monkeypatch)
- saturday = datetime(2026, 8, 8, 9, 0, tzinfo=ZoneInfo("Asia/Shanghai"))
- monkeypatch.setattr(tc, "_now_in_market_tz", lambda code: saturday)
+ saturday = datetime(2026, 8, 8, 22, 0, tzinfo=ZoneInfo("Asia/Shanghai"))
+ monkeypatch.setattr(tc, "_now_in_market_tz", lambda code: saturday.astimezone(tc._market_tz(code)))
sched = PaperTradingScheduler(timezone="Asia/Shanghai")
asyncio.run(sched._premarket_job())
@@ -199,8 +165,8 @@ def test_法定节假日不发盘前计划和日终摘要(monkeypatch, loaded_ca
calls = _patch_notifiers(monkeypatch)
# 10/3 是周六:三市场全休 → 必须跳过
- holiday = datetime(2026, 10, 3, 9, 0, tzinfo=ZoneInfo("Asia/Shanghai"))
- monkeypatch.setattr(tc, "_now_in_market_tz", lambda code: holiday)
+ holiday = datetime(2026, 10, 3, 22, 0, tzinfo=ZoneInfo("Asia/Shanghai"))
+ monkeypatch.setattr(tc, "_now_in_market_tz", lambda code: holiday.astimezone(tc._market_tz(code)))
sched = PaperTradingScheduler(timezone="Asia/Shanghai")
asyncio.run(sched._premarket_job())
@@ -215,7 +181,7 @@ def test_交易日照常发盘前计划和日终摘要(monkeypatch, loaded_calen
calls = _patch_notifiers(monkeypatch)
monday = datetime(2026, 8, 10, 9, 0, tzinfo=ZoneInfo("Asia/Shanghai"))
- monkeypatch.setattr(tc, "_now_in_market_tz", lambda code: monday)
+ monkeypatch.setattr(tc, "_now_in_market_tz", lambda code: monday.astimezone(tc._market_tz(code)))
sched = PaperTradingScheduler(timezone="Asia/Shanghai")
asyncio.run(sched._premarket_job())
@@ -242,8 +208,8 @@ def _fake_refresh(**kwargs):
monkeypatch.setattr(
"src.modules.research.context_scheduler.refresh_strategy_signals", _fake_refresh
)
- saturday = datetime(2026, 8, 8, 9, 15, tzinfo=ZoneInfo("Asia/Shanghai"))
- monkeypatch.setattr(tc, "_now_in_market_tz", lambda code: saturday)
+ saturday = datetime(2026, 8, 8, 22, 15, tzinfo=ZoneInfo("Asia/Shanghai"))
+ monkeypatch.setattr(tc, "_now_in_market_tz", lambda code: saturday.astimezone(tc._market_tz(code)))
sched = ContextMaintenanceScheduler(timezone="Asia/Shanghai")
asyncio.run(sched._refresh_opportunities_job())
@@ -265,7 +231,7 @@ def _fake_refresh(**kwargs):
"src.modules.research.context_scheduler.refresh_strategy_signals", _fake_refresh
)
monday = datetime(2026, 8, 10, 9, 15, tzinfo=ZoneInfo("Asia/Shanghai"))
- monkeypatch.setattr(tc, "_now_in_market_tz", lambda code: monday)
+ monkeypatch.setattr(tc, "_now_in_market_tz", lambda code: monday.astimezone(tc._market_tz(code)))
sched = ContextMaintenanceScheduler(timezone="Asia/Shanghai")
asyncio.run(sched._refresh_opportunities_job())
@@ -286,8 +252,8 @@ def _fake_refresh(**kwargs):
monkeypatch.setattr(
"src.modules.research.context_scheduler.refresh_strategy_signals", _fake_refresh
)
- saturday = datetime(2026, 8, 8, 9, 15, tzinfo=ZoneInfo("Asia/Shanghai"))
- monkeypatch.setattr(tc, "_now_in_market_tz", lambda code: saturday)
+ saturday = datetime(2026, 8, 8, 22, 15, tzinfo=ZoneInfo("Asia/Shanghai"))
+ monkeypatch.setattr(tc, "_now_in_market_tz", lambda code: saturday.astimezone(tc._market_tz(code)))
sched = ContextMaintenanceScheduler(timezone="Asia/Shanghai")
asyncio.run(sched.refresh_opportunities_once())
diff --git a/tests/test_trading_execution_guards.py b/tests/test_trading_execution_guards.py
new file mode 100644
index 000000000..cf6a10ef3
--- /dev/null
+++ b/tests/test_trading_execution_guards.py
@@ -0,0 +1,250 @@
+"""Regression coverage for mixed-market holidays and all execution entry points."""
+import asyncio
+from datetime import date, datetime
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, Mock
+from zoneinfo import ZoneInfo
+
+import pytest
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker
+from sqlalchemy.pool import StaticPool
+
+from src.platform.persistence.database import Base
+from src.platform.persistence.models import (
+ AgentConfig, EntryCandidate, EntryCandidateOutcome, PaperTradingAccount,
+ PaperTradingPosition, PaperTradingTrade, PriceAlertRule, Stock, StockAgent,
+ StrategySignalRun, StrategyOutcome,
+)
+from src.platform.scheduling import trading_calendar as calendar
+from src.platform.scheduling.schedule_parser import preview_schedule
+
+
+def freeze(monkeypatch, instant):
+ monkeypatch.setattr(calendar, "_now_in_market_tz", lambda code: instant.astimezone(calendar._market_tz(code)))
+
+
+@pytest.fixture
+def memory_db():
+ engine = create_engine("sqlite://", poolclass=StaticPool, connect_args={"check_same_thread": False})
+ Base.metadata.create_all(engine)
+ factory = sessionmaker(bind=engine, expire_on_commit=False)
+ yield factory
+ engine.dispose()
+
+
+def test_preview_skips_national_holiday_and_intraday_lunch():
+ start = datetime(2026, 10, 3, 12, tzinfo=ZoneInfo("Asia/Shanghai"))
+ runs = preview_schedule("0 9 * * 1-5", count=3, timezone="Asia/Shanghai", start=start, markets=["CN"])
+ assert [run.date() for run in runs] == [date(2026, 10, 8), date(2026, 10, 9), date(2026, 10, 12)]
+ lunch = datetime(2026, 10, 8, 11, 35, tzinfo=ZoneInfo("Asia/Shanghai"))
+ intraday = preview_schedule("*/5 9-15 * * 1-5", count=2, timezone="Asia/Shanghai", start=lunch,
+ markets=["CN"], trading_hours_only=True)
+ assert [(run.hour, run.minute) for run in intraday] == [(13, 0), (13, 5)]
+ assert preview_schedule("0 9 * * 1-5", markets=[], start=start) == []
+
+
+def test_next_us_open_is_correct_in_beijing_across_dst(monkeypatch):
+ from src.modules.market.api.stocks import get_market_calendars
+ for instant, expected in [
+ (datetime(2026, 10, 3, 8, tzinfo=ZoneInfo("Asia/Shanghai")), "2026-10-05T21:30:00+08:00"),
+ (datetime(2026, 11, 7, 8, tzinfo=ZoneInfo("Asia/Shanghai")), "2026-11-09T22:30:00+08:00"),
+ ]:
+ freeze(monkeypatch, instant)
+ data = get_market_calendars(days=14, timezone="Asia/Shanghai")
+ assert {market["market"] for market in data["markets"]} == {"CN", "HK", "US"}
+ assert all(market["days"][0]["date"] == instant.date().isoformat() for market in data["markets"])
+ us = next(market for market in data["markets"] if market["market"] == "US")
+ assert datetime.fromisoformat(us["next_open"]).astimezone(ZoneInfo("Asia/Shanghai")).isoformat() == expected
+
+
+def test_batch_agent_filters_watchlist_and_portfolio_before_analysis(monkeypatch):
+ from src.modules.automation.agent_scheduler import AgentScheduler
+ from src.modules.automation.base import AgentContext, PortfolioInfo, AccountInfo, PositionInfo
+ from src.platform.runtime.config import AppConfig, Settings, StockConfig
+ from src.platform.marketdata.models import MarketCode
+ freeze(monkeypatch, datetime(2026, 10, 1, 22, tzinfo=ZoneInfo("Asia/Shanghai")))
+ cn = StockConfig(symbol="601238", name="CN", market=MarketCode.CN)
+ us = StockConfig(symbol="AAPL", name="US", market=MarketCode.US)
+ positions = [PositionInfo(account_id=1, account_name="Account", stock_id=i, symbol=stock.symbol,
+ name=stock.name, market=stock.market, cost_price=10, quantity=100)
+ for i, stock in enumerate([cn, us])]
+ context = AgentContext(ai_client=Mock(), notifier=Mock(), config=AppConfig(settings=Settings(), watchlist=[cn, us]),
+ portfolio=PortfolioInfo([AccountInfo(1, "Account", 0, positions)]))
+ agent = SimpleNamespace(display_name="Daily report", run=AsyncMock(return_value=SimpleNamespace(raw_data={}, content="Report")))
+ scheduler = AgentScheduler()
+ scheduler.agents["daily_report"] = agent
+ scheduler.set_context_builder(lambda name: context)
+ record = Mock()
+ monkeypatch.setattr("src.modules.automation.agent_scheduler.record_agent_run", record)
+ asyncio.run(scheduler._run_agent("daily_report"))
+ scoped = agent.run.call_args.args[0]
+ assert [stock.market for stock in scoped.watchlist] == [MarketCode.US]
+ assert [pos.market for pos in scoped.portfolio.all_positions] == [MarketCode.US]
+ assert len(context.watchlist) == len(context.portfolio.all_positions) == 2
+ # A pure CN batch must stop before collect/AI and must not produce an inbox event.
+ agent.run.reset_mock()
+ record.reset_mock()
+ asyncio.run(scheduler._run_agent("daily_report", stock_keys=(("CN", "601238"),)))
+ agent.run.assert_not_awaited()
+ record.assert_not_called()
+
+
+def test_effective_stock_override_is_registered_without_global_duplicate(monkeypatch, memory_db):
+ import server
+ from src.modules.automation.scheduling_policy import schedule_plans
+ with memory_db() as db:
+ cfg = AgentConfig(name="intraday_monitor", display_name="Monitor", enabled=True,
+ schedule="*/5 9-15 * * 1-5", execution_mode="single")
+ inherited = Stock(symbol="601238", name="CN", market="CN")
+ custom = Stock(symbol="AAPL", name="US", market="US")
+ db.add_all([cfg, inherited, custom]); db.flush()
+ db.add_all([StockAgent(stock_id=inherited.id, agent_name=cfg.name),
+ StockAgent(stock_id=custom.id, agent_name=cfg.name, schedule="*/10 21-23 * * 1-5")])
+ db.commit()
+ plans = schedule_plans(db, cfg)
+ assert len(plans) == 2
+ assert plans[0].stock_keys == (("CN", "601238"),)
+ assert plans[1].stock_keys == (("US", "AAPL"),)
+ assert plans[1].stock_agent_id is not None
+ monkeypatch.setattr(server, "SessionLocal", memory_db)
+ scheduler = server.build_scheduler()
+ jobs = scheduler.scheduler.get_jobs()
+ assert len(jobs) == 2
+ assert {job.args[1] for job in jobs} == {plan.stock_keys for plan in plans}
+
+
+def test_paper_scan_does_not_buy_or_sell_closed_markets_when_us_is_open(monkeypatch, memory_db):
+ from src.modules.paper_trading import paper_trading_engine as module
+ freeze(monkeypatch, datetime(2026, 10, 1, 22, tzinfo=ZoneInfo("Asia/Shanghai")))
+ monkeypatch.setattr(module, "SessionLocal", memory_db)
+ with memory_db() as db:
+ db.add(PaperTradingAccount(enabled=True, initial_capital=1_000_000, current_capital=1_000_000,
+ market_allocations={"CN": .5, "HK": 0, "US": .5}))
+ for market, symbol in [("CN", "601238"), ("US", "AAPL")]:
+ db.add(StrategySignalRun(strategy_code="trend_follow", snapshot_date="2026-09-30",
+ stock_symbol=symbol, stock_market=market, status="active", action="buy",
+ entry_low=9, entry_high=11, rank_score=90))
+ db.add(PaperTradingPosition(stock_symbol="600519", stock_market="CN", quantity=100,
+ entry_price=15, current_price=10, stop_loss=12, status="open",
+ opened_at=datetime(2026, 9, 1)))
+ db.commit()
+ engine = module.PaperTradingEngine()
+ quote_requests = []
+ def quotes(pairs):
+ quote_requests.extend(pairs)
+ return {(market, symbol): {"current_price": 10} for symbol, market in pairs}
+ monkeypatch.setattr(engine, "_fetch_quotes_map", quotes)
+ result = engine._scan_sync()
+ assert result["status"] == "ok"
+ assert result["opened"] == 1 and result["closed"] == 0
+ assert quote_requests and all(market == "US" for _, market in quote_requests)
+ with memory_db() as db:
+ assert db.query(PaperTradingPosition).filter_by(stock_symbol="600519").one().status == "open"
+ assert db.query(PaperTradingTrade).count() == 0
+ # Manual close is also a fill and cannot use a last-known holiday price.
+ fetch = Mock(side_effect=AssertionError("closed-market quote must not be requested"))
+ monkeypatch.setattr(module, "md_quote_rows", fetch)
+ with memory_db() as db:
+ position = db.query(PaperTradingPosition).filter_by(stock_symbol="600519").one()
+ assert engine.close_position_manual(position.id)["ok"] is False
+ fetch.assert_not_called()
+
+
+def test_alerts_filter_before_quotes_and_manual_non_dry_scan_cannot_bypass(monkeypatch, memory_db):
+ from src.modules.market import price_alert_engine as module
+ freeze(monkeypatch, datetime(2026, 10, 1, 22, tzinfo=ZoneInfo("Asia/Shanghai")))
+ monkeypatch.setattr(module, "SessionLocal", memory_db)
+ with memory_db() as db:
+ stock = Stock(symbol="601238", name="CN", market="CN")
+ db.add(stock); db.flush()
+ db.add(PriceAlertRule(stock_id=stock.id, enabled=True, name="Alert", market_hours_mode="always",
+ condition_group={"op": "and", "items": [{"type": "price", "op": ">", "value": 1}]}))
+ db.commit()
+ engine = module.PriceAlertEngine()
+ fetch = AsyncMock(return_value={})
+ monkeypatch.setattr(engine, "_fetch_quotes_map", fetch)
+ result = asyncio.run(engine.scan_once(bypass_market_hours=True, dry_run=False))
+ assert result["triggered"] == 0
+ assert result["items"][0]["reason"] == "non_trading_day"
+ assert fetch.call_args.args[0] == []
+
+
+def test_opportunity_refresh_propagates_only_eligible_markets(monkeypatch):
+ from src.modules.research.context_scheduler import ContextMaintenanceScheduler
+ freeze(monkeypatch, datetime(2026, 10, 1, 22, tzinfo=ZoneInfo("Asia/Shanghai")))
+ refresh = Mock(return_value={"count": 0})
+ monkeypatch.setattr("src.modules.research.context_scheduler.refresh_strategy_signals", refresh)
+ asyncio.run(ContextMaintenanceScheduler()._refresh_opportunities_job())
+ assert refresh.call_args.kwargs["markets"] == ["US"]
+
+
+def test_outcome_waits_for_actual_completed_bars_and_ignores_intraday_tail(monkeypatch):
+ from src.platform.marketdata.outcome_prices import completed_outcome_bar
+ rows = [SimpleNamespace(date="2026-09-30", close=100), SimpleNamespace(date="2026-10-08", close=110)]
+ freeze(monkeypatch, datetime(2026, 10, 1, 22, tzinfo=ZoneInfo("Asia/Shanghai")))
+ assert completed_outcome_bar(rows[:1], date(2026, 9, 30), 1, "CN") is None
+ freeze(monkeypatch, datetime(2026, 10, 8, 10, tzinfo=ZoneInfo("Asia/Shanghai")))
+ assert completed_outcome_bar(rows, date(2026, 9, 30), 1, "CN") is None
+ freeze(monkeypatch, datetime(2026, 10, 8, 15, 30, tzinfo=ZoneInfo("Asia/Shanghai")))
+ assert completed_outcome_bar(rows, date(2026, 9, 30), 1, "CN") == (date(2026, 10, 8), 110)
+ assert completed_outcome_bar(rows, date(2026, 9, 30), 3, "CN") is None
+
+
+def test_automatic_ta_linkage_does_not_escape_a_manual_holiday_analysis(monkeypatch):
+ from src.modules.automation.tradingagents import operations
+ freeze(monkeypatch, datetime(2026, 10, 1, 22, tzinfo=ZoneInfo("Asia/Shanghai")))
+ trigger = Mock()
+ monkeypatch.setattr(operations, "fire_and_forget_trigger", trigger)
+ assert operations.try_auto_trigger(SimpleNamespace(symbol="601238", market="CN", change_pct=10)) is None
+ trigger.assert_not_called()
+
+
+def test_half_day_summary_runs_only_at_actual_market_close_plus_30(monkeypatch):
+ from src.modules.paper_trading.paper_trading_scheduler import PaperTradingScheduler
+ summary = AsyncMock()
+ monkeypatch.setattr("src.modules.paper_trading.paper_trading_notifier.send_daily_summary", summary)
+ scheduler = PaperTradingScheduler()
+ freeze(monkeypatch, datetime(2026, 12, 24, 12, 30, tzinfo=ZoneInfo("Asia/Hong_Kong")))
+ asyncio.run(scheduler._summary_job("HK"))
+ summary.assert_awaited_once_with(markets=["HK"])
+ freeze(monkeypatch, datetime(2026, 12, 24, 16, 30, tzinfo=ZoneInfo("Asia/Hong_Kong")))
+ asyncio.run(scheduler._summary_job("HK"))
+ assert summary.await_count == 1
+
+
+def test_alert_daily_limit_uses_market_date_not_utc_midnight(monkeypatch):
+ from src.modules.market.price_alert_engine import PriceAlertEngine
+ freeze(monkeypatch, datetime(2026, 10, 3, 8, tzinfo=ZoneInfo("Asia/Shanghai")))
+ rule = SimpleNamespace(enabled=True, expire_at=None, stock=SimpleNamespace(market="US"),
+ market_hours_mode="always", trigger_date="2026-10-02", trigger_count_today=1,
+ max_triggers_per_day=1, repeat_mode="repeat", last_trigger_at=None)
+ # NY Friday remains Friday after UTC has crossed midnight.
+ now = datetime(2026, 10, 3, 0, 5, tzinfo=ZoneInfo("UTC"))
+ assert PriceAlertEngine()._can_trigger(rule, now) == (False, "daily_limit")
+ assert rule.trigger_date == "2026-10-02"
+
+
+def test_paper_fill_rejects_a_known_previous_day_quote(monkeypatch, memory_db):
+ from src.modules.paper_trading import paper_trading_engine as module
+ freeze(monkeypatch, datetime(2026, 10, 8, 10, tzinfo=ZoneInfo("Asia/Shanghai")))
+ monkeypatch.setattr(module, "SessionLocal", memory_db)
+ with memory_db() as db:
+ db.add(PaperTradingAccount(enabled=True, initial_capital=1_000_000, current_capital=1_000_000,
+ market_allocations={"CN": 1, "HK": 0, "US": 0}))
+ db.add(StrategySignalRun(strategy_code="trend_follow", snapshot_date="2026-09-30",
+ stock_symbol="601238", stock_market="CN", status="active", action="buy",
+ entry_low=9, entry_high=11, rank_score=90))
+ db.add(PaperTradingPosition(stock_symbol="600519", stock_market="CN", quantity=100,
+ entry_price=15, current_price=10, stop_loss=12, status="open"))
+ db.commit()
+ engine = module.PaperTradingEngine()
+ monkeypatch.setattr(engine, "_fetch_quotes_map", lambda pairs: {
+ (market, symbol): {"current_price": 10, "quote_date": "2026-09-30"} for symbol, market in pairs
+ })
+ result = engine._scan_sync()
+ assert result["opened"] == result["closed"] == 0
+ monkeypatch.setattr(module, "md_quote_rows", lambda *args: [{"current_price": 10, "quote_date": "2026-09-30"}])
+ with memory_db() as db:
+ position = db.query(PaperTradingPosition).one()
+ assert engine.close_position_manual(position.id)["ok"] is False
diff --git a/tests/test_tradingagents_auto_trigger.py b/tests/test_tradingagents_auto_trigger.py
index ff2524c63..ea2ef5c22 100644
--- a/tests/test_tradingagents_auto_trigger.py
+++ b/tests/test_tradingagents_auto_trigger.py
@@ -132,8 +132,10 @@ def test_try_auto_trigger_fires_when_should():
"""try_auto_trigger 在满足条件时调 fire_and_forget_trigger"""
stock = MagicMock()
stock.symbol = "601238"
+ stock.market = "CN"
stock.change_pct = 8.0
- with patch("src.modules.automation.tradingagents.operations.should_auto_trigger", return_value=(True, "test")), \
+ with patch("src.platform.scheduling.trading_calendar.market_status", return_value="trading"), \
+ patch("src.modules.automation.tradingagents.operations.should_auto_trigger", return_value=(True, "test")), \
patch("src.modules.automation.tradingagents.operations.fire_and_forget_trigger", return_value="trace-abc") as fire:
result = auto_trigger.try_auto_trigger(stock)
assert result == "trace-abc"