diff --git a/.env.example b/.env.example index e2a05fe..3c564c0 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,15 @@ AI_MODEL=glm-4 NOTIFY_TELEGRAM_BOT_TOKEN=your_bot_token NOTIFY_TELEGRAM_CHAT_ID=your_chat_id +# ETF 机会提醒邮件配置 - SMTP/QQ邮箱 +# 如果用 QQ 邮箱发信,SMTP_PASSWORD 填 QQ 邮箱“授权码”,不是登录密码。 +ETF_ALERT_TO=your_qq_number@qq.com +SMTP_HOST=smtp.qq.com +SMTP_PORT=465 +SMTP_USER=your_sender@qq.com +SMTP_PASSWORD=your_qq_smtp_authorization_code +SMTP_FROM=your_sender@qq.com + # 代理配置(国内访问 Telegram 可能需要) # HTTP_PROXY=http://127.0.0.1:7890 diff --git a/frontend/packages/api/src/index.ts b/frontend/packages/api/src/index.ts index 2b42751..14333f8 100644 --- a/frontend/packages/api/src/index.ts +++ b/frontend/packages/api/src/index.ts @@ -14,3 +14,4 @@ export * from './home' export * from './paper-trading' export * from './chat' export * from './tradingagents' +export * from './watchlist' diff --git a/frontend/packages/api/src/watchlist.ts b/frontend/packages/api/src/watchlist.ts new file mode 100644 index 0000000..c0d78bc --- /dev/null +++ b/frontend/packages/api/src/watchlist.ts @@ -0,0 +1,46 @@ +import { fetchAPI } from './client' + +export type WatchDecisionLabel = + | '观察' + | '买入候选' + | '继续持有' + | '加仓观察' + | '减仓警告' + | '止损触发' + | '禁止追高' + +export interface WatchlistSignalPayload { + symbol: string + name?: string + market?: string + quote?: Record + technical?: Record + position?: Record + news_flags?: string[] + sector_strength?: number | null + already_no_chase_today?: boolean +} + +export interface WatchlistSignalResult { + symbol: string + name: string + market: string + label: WatchDecisionLabel + score: number + reasons: string[] + risks: string[] + confirm_conditions: string[] + invalidation_conditions: string[] + risk_level: string + generated_at: string +} + +export const watchlistApi = { + evaluateSignal: (payload: WatchlistSignalPayload) => + fetchAPI('/watchlist/signals/evaluate', { + method: 'POST', + body: JSON.stringify(payload), + timeoutMs: 30_000, + }), +} + diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml index dee51e9..7e24990 100644 --- a/frontend/pnpm-workspace.yaml +++ b/frontend/pnpm-workspace.yaml @@ -1,2 +1,4 @@ packages: - "packages/*" +allowBuilds: + esbuild: true diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 995be46..c253466 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,10 +1,11 @@ import { useState, useEffect, useRef } from 'react' import { Routes, Route, NavLink, useLocation, Navigate } from 'react-router-dom' -import { TrendingUp, Bot, ScrollText, Settings, List, Database, Clock, LayoutDashboard, Github, BellRing, Sparkles, Activity } from 'lucide-react' +import { TrendingUp, Bot, ScrollText, Settings, List, Database, Clock, LayoutDashboard, Github, BellRing, Sparkles, Activity, Radar } from 'lucide-react' import { useTheme } from '@/hooks/use-theme' import { appApi, fetchAPI, isAuthenticated } from '@panwatch/api' import DashboardPage from '@/pages/Dashboard' import OpportunitiesPage from '@/pages/Opportunities' +import WatchlistPage from '@/pages/Watchlist' import StocksPage from '@/pages/Stocks' import AgentsPage from '@/pages/Agents' import SettingsPage from '@/pages/Settings' @@ -24,6 +25,7 @@ import { Button } from '@panwatch/base-ui/components/ui/button' const navItems = [ { to: '/', icon: LayoutDashboard, label: '首页' }, + { to: '/watchlist', icon: Radar, label: '盯盘' }, { to: '/portfolio', icon: List, label: '持仓' }, { to: '/opportunities', icon: Sparkles, label: '机会' }, { to: '/paper-trading', icon: Activity, label: '模拟盘' }, @@ -256,6 +258,7 @@ function App() {
} /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/Watchlist.tsx b/frontend/src/pages/Watchlist.tsx new file mode 100644 index 0000000..3dca5e5 --- /dev/null +++ b/frontend/src/pages/Watchlist.tsx @@ -0,0 +1,498 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Activity, AlertTriangle, CheckCircle2, Eye, RefreshCw, ShieldAlert, Target, TrendingUp } from 'lucide-react' +import { fetchAPI, stocksApi, watchlistApi, type WatchlistSignalResult } from '@panwatch/api' +import { Button } from '@panwatch/base-ui/components/ui/button' +import { Badge } from '@panwatch/base-ui/components/ui/badge' +import { Skeleton } from '@panwatch/base-ui/components/ui/skeleton' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@panwatch/base-ui/components/ui/select' +import { Switch } from '@panwatch/base-ui/components/ui/switch' + +interface StockItem { + id: number + symbol: string + name: string + market: string +} + +interface PositionItem { + id: number + stock_id: number + symbol: string + name: string + market: string + cost_price: number + quantity: number + trading_style: string + current_price: number | null + change_pct: number | null + pnl_pct: number | null +} + +interface AccountSummary { + id: number + name: string + positions: PositionItem[] +} + +interface PortfolioSummary { + accounts: AccountSummary[] +} + +interface QuoteResponse { + symbol: string + market: string + current_price: number | null + change_pct: number | null +} + +interface KlineSummaryResponse { + symbol: string + market: string + summary: Record +} + +interface WatchRow { + key: string + symbol: string + name: string + market: string + source: 'position' | 'watch' + accountName?: string + quantity?: number + costPrice?: number + quote: Record + technical: Record + decision: WatchlistSignalResult +} + +interface WatchTarget { + key: string + symbol: string + name: string + market: string + source: 'position' | 'watch' + accountName?: string + position: PositionItem | null +} + +interface WatchDataset { + targets: WatchTarget[] + klineMap: Map> +} + +const marketLabel = (market: string) => market === 'HK' ? '港股' : market === 'US' ? '美股' : 'A股' +const AUTO_REFRESH_KEY = 'panwatch_watchlist_auto_refresh' +const REFRESH_INTERVAL_KEY = 'panwatch_watchlist_refresh_interval' +const KLINE_REFRESH_MS = 60_000 + +const labelTone: Record = { + 买入候选: 'bg-rose-500/15 text-rose-500 border-rose-500/30', + 加仓观察: 'bg-emerald-500/15 text-emerald-500 border-emerald-500/30', + 继续持有: 'bg-blue-500/15 text-blue-500 border-blue-500/30', + 减仓警告: 'bg-amber-500/15 text-amber-500 border-amber-500/30', + 止损触发: 'bg-red-500/15 text-red-500 border-red-500/30', + 禁止追高: 'bg-orange-500/15 text-orange-500 border-orange-500/30', + 观察: 'bg-accent text-muted-foreground border-border/60', +} + +const labelPriority: Record = { + 止损触发: 6, + 减仓警告: 5, + 禁止追高: 4, + 买入候选: 3, + 加仓观察: 2, + 继续持有: 1, + 观察: 0, +} + +const toNumber = (value: unknown): number | null => { + if (typeof value === 'number' && Number.isFinite(value)) return value + if (typeof value === 'string' && value.trim()) { + const n = Number(value) + if (Number.isFinite(n)) return n + } + return null +} + +const formatPrice = (value: unknown) => { + const n = toNumber(value) + if (n == null) return '--' + return n >= 100 ? n.toFixed(2) : n.toFixed(3).replace(/0+$/, '').replace(/\.$/, '') +} + +const formatPct = (value: unknown) => { + const n = toNumber(value) + if (n == null) return '--' + return `${n >= 0 ? '+' : ''}${n.toFixed(2)}%` +} + +const makeQuotePayload = (quote?: QuoteResponse, position?: PositionItem) => ({ + current_price: quote?.current_price ?? position?.current_price ?? null, + change_pct: quote?.change_pct ?? position?.change_pct ?? null, +}) + +const buildPositionPayload = (position: PositionItem | null, technical: Record) => { + const support = toNumber(technical.support ?? technical.support_m ?? technical.support_s) + const resistance = toNumber(technical.resistance ?? technical.resistance_m ?? technical.resistance_s) + if (position) { + const cost = Number(position.cost_price || 0) + const supportStop = support && support > 0 ? support * 0.985 : null + const costStop = cost > 0 ? cost * 0.94 : null + const stopLoss = supportStop && costStop ? Math.max(supportStop, costStop) : supportStop ?? costStop + const targetPrice = resistance && resistance > 0 ? Math.max(resistance, cost * 1.1) : cost > 0 ? cost * 1.12 : null + return { + has_position: true, + avg_cost: cost, + quantity: position.quantity, + stop_loss: stopLoss, + target_price: targetPrice, + trading_style: position.trading_style || 'swing', + } + } + return { + has_position: false, + stop_loss: support && support > 0 ? support * 0.99 : null, + target_price: resistance && resistance > 0 ? resistance : null, + } +} + +const getDecisionIcon = (label: string) => { + if (label === '止损触发') return ShieldAlert + if (label === '减仓警告' || label === '禁止追高') return AlertTriangle + if (label === '买入候选' || label === '加仓观察') return TrendingUp + if (label === '继续持有') return CheckCircle2 + return Eye +} + +export default function WatchlistPage() { + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [rows, setRows] = useState([]) + const [lastRunAt, setLastRunAt] = useState('') + const [autoRefresh, setAutoRefresh] = useState(() => localStorage.getItem(AUTO_REFRESH_KEY) === 'true') + const [refreshInterval, setRefreshInterval] = useState(() => { + const raw = Number(localStorage.getItem(REFRESH_INTERVAL_KEY) || 10) + return [5, 10, 30, 60].includes(raw) ? raw : 10 + }) + const [lastKlineAt, setLastKlineAt] = useState(0) + const datasetRef = useRef(null) + const loadingRef = useRef(false) + + useEffect(() => { + localStorage.setItem(AUTO_REFRESH_KEY, String(autoRefresh)) + }, [autoRefresh]) + + useEffect(() => { + localStorage.setItem(REFRESH_INTERVAL_KEY, String(refreshInterval)) + }, [refreshInterval]) + + const buildTargets = useCallback(async (): Promise => { + const [stocks, portfolio] = await Promise.all([ + stocksApi.list() as Promise, + fetchAPI('/portfolio/summary?include_quotes=false'), + ]) + + const positions = (portfolio.accounts || []).flatMap((account) => + (account.positions || []).map((position) => ({ ...position, accountName: account.name })), + ) + const positionKeys = new Set(positions.map((p) => `${p.market}:${p.symbol}`)) + return [ + ...positions.map((p) => ({ + key: `position:${p.market}:${p.symbol}:${p.id}`, + symbol: p.symbol, + name: p.name, + market: p.market, + source: 'position' as const, + accountName: p.accountName, + position: p, + })), + ...stocks + .filter((stock) => !positionKeys.has(`${stock.market}:${stock.symbol}`)) + .map((stock) => ({ + key: `watch:${stock.market}:${stock.symbol}:${stock.id}`, + symbol: stock.symbol, + name: stock.name, + market: stock.market, + source: 'watch' as const, + position: null, + })), + ] + }, []) + + const fetchKlines = useCallback(async (targets: WatchTarget[]) => { + const klineRows = await Promise.all( + targets.map((target) => + fetchAPI( + `/klines/${encodeURIComponent(target.symbol)}/summary?market=${encodeURIComponent(target.market)}`, + { timeoutMs: 25_000 }, + ).catch(() => ({ + symbol: target.symbol, + market: target.market, + summary: {}, + })), + ), + ) + setLastKlineAt(Date.now()) + return new Map(klineRows.map((row) => [`${row.market}:${row.symbol}`, row.summary || {}])) + }, []) + + const evaluateTargets = useCallback(async (targets: WatchTarget[], klineMap: Map>) => { + if (!targets.length) { + setRows([]) + setLastRunAt(new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })) + return + } + + const quoteItems = targets.map((target) => ({ symbol: target.symbol, market: target.market })) + const quoteRows = await fetchAPI('/quotes/batch', { + method: 'POST', + body: JSON.stringify({ items: quoteItems }), + timeoutMs: 25_000, + }).catch(() => []) + + const quoteMap = new Map(quoteRows.map((quote) => [`${quote.market}:${quote.symbol}`, quote])) + const decisions = await Promise.all( + targets.map(async (target) => { + const quote = makeQuotePayload(quoteMap.get(`${target.market}:${target.symbol}`), target.position || undefined) + const technical = klineMap.get(`${target.market}:${target.symbol}`) || {} + const decision = await watchlistApi.evaluateSignal({ + symbol: target.symbol, + name: target.name, + market: target.market, + quote, + technical, + position: buildPositionPayload(target.position, technical), + }) + return { + key: target.key, + symbol: target.symbol, + name: target.name, + market: target.market, + source: target.source, + accountName: target.accountName, + quantity: target.position?.quantity, + costPrice: target.position?.cost_price, + quote, + technical, + decision, + } + }), + ) + + decisions.sort((a, b) => { + const priorityDelta = (labelPriority[b.decision.label] || 0) - (labelPriority[a.decision.label] || 0) + if (priorityDelta !== 0) return priorityDelta + return Number(b.decision.score || 0) - Number(a.decision.score || 0) + }) + setRows(decisions) + setLastRunAt(new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })) + }, []) + + const load = useCallback(async (options: { refreshKlines?: boolean; silent?: boolean } = {}) => { + if (loadingRef.current) return + loadingRef.current = true + if (!options.silent) setLoading(true) + setError('') + try { + let dataset = datasetRef.current + if (!dataset || options.refreshKlines) { + const targets = await buildTargets() + const klineMap = await fetchKlines(targets) + dataset = { targets, klineMap } + datasetRef.current = dataset + } else if (Date.now() - lastKlineAt > KLINE_REFRESH_MS) { + const klineMap = await fetchKlines(dataset.targets) + dataset = { targets: dataset.targets, klineMap } + datasetRef.current = dataset + } + await evaluateTargets(dataset.targets, dataset.klineMap) + } catch (e) { + setError(e instanceof Error ? e.message : '盯盘评估失败') + setRows([]) + } finally { + loadingRef.current = false + if (!options.silent) setLoading(false) + } + }, [buildTargets, evaluateTargets, fetchKlines, lastKlineAt]) + + useEffect(() => { + load() + }, [load]) + + useEffect(() => { + if (!autoRefresh) return + const id = window.setInterval(() => { + load({ silent: true }) + }, refreshInterval * 1000) + return () => window.clearInterval(id) + }, [autoRefresh, load, refreshInterval]) + + const summary = useMemo(() => { + const urgent = rows.filter((row) => ['止损触发', '减仓警告', '禁止追高'].includes(row.decision.label)).length + const candidates = rows.filter((row) => ['买入候选', '加仓观察'].includes(row.decision.label)).length + const holdings = rows.filter((row) => row.source === 'position').length + return { urgent, candidates, holdings, total: rows.length } + }, [rows]) + + return ( +
+
+
+
+
+ +
+
+

今日盯盘

+

规则信号先行,AI 只负责解释;所有动作保留人工确认。

+
+
+
+
+ {lastRunAt && 最近评估 {lastRunAt}} +
+ + 自动 + +
+ +
+
+ + {error && ( +
+ {error} +
+ )} + +
+ + + + +
+ + {loading && rows.length === 0 ? ( +
+ {[0, 1, 2, 3].map((item) => ( +
+ + + +
+ ))} +
+ ) : rows.length === 0 ? ( +
+
+ +
+
暂无可评估标的
+
先在持仓页添加自选股或持仓。
+
+ ) : ( +
+ {rows.map((row) => ( + + ))} +
+ )} +
+ ) +} + +function MetricCard({ + icon: Icon, + label, + value, + tone = 'neutral', +}: { + icon: typeof Activity + label: string + value: number + tone?: 'neutral' | 'risk' | 'positive' +}) { + const toneClass = tone === 'risk' ? 'text-red-500' : tone === 'positive' ? 'text-emerald-500' : 'text-primary' + return ( +
+
+ + {label} +
+
{value}
+
+ ) +} + +function DecisionCard({ row }: { row: WatchRow }) { + const Icon = getDecisionIcon(row.decision.label) + const pct = toNumber(row.quote.change_pct) + const price = row.quote.current_price + const support = row.technical.support ?? row.technical.support_m ?? row.technical.support_s + const resistance = row.technical.resistance ?? row.technical.resistance_m ?? row.technical.resistance_s + + return ( +
+
+
+
+ {row.name || row.symbol} + {row.symbol} + {marketLabel(row.market)} + {row.source === 'position' ? row.accountName || '持仓' : '自选'} +
+
+ 现价 {formatPrice(price)} + = 0 ? 'text-rose-500' : 'text-emerald-500'}>{formatPct(pct)} + {row.costPrice != null && 成本 {formatPrice(row.costPrice)}} + {row.quantity != null && 数量 {row.quantity}} + 支撑 {formatPrice(support)} + 压力 {formatPrice(resistance)} +
+
+ +
+ + + {row.decision.label} + + {row.decision.score} +
+
+ +
+ + + +
+
+ ) +} + +function InfoBlock({ title, items, mutedEmpty = false }: { title: string; items: string[]; mutedEmpty?: boolean }) { + return ( +
+
{title}
+
+ {items.slice(0, 4).map((item, index) => ( +
+ {item} +
+ ))} +
+
+ ) +} diff --git a/scripts/monitor_etf_alerts.py b/scripts/monitor_etf_alerts.py new file mode 100644 index 0000000..baa2a6a --- /dev/null +++ b/scripts/monitor_etf_alerts.py @@ -0,0 +1,414 @@ +#!/usr/bin/env python3 +"""Monitor selected A-share ETFs and email when rule-based entry conditions appear.""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import smtplib +import sys +import time +from dataclasses import dataclass +from datetime import date, datetime, time as dtime +from email.message import EmailMessage +from pathlib import Path +from typing import Any +from zoneinfo import ZoneInfo + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from src.collectors.kline_collector import KlineCollector +from src.core.providers import ProviderRequest, get_quote_orchestrator +from src.core.signals.a_share_decision import evaluate_a_share_decision +from src.models.market import MarketCode + + +CN_TZ = ZoneInfo("Asia/Shanghai") +DEFAULT_SYMBOLS = { + "159995": "芯片ETF华夏", + "159516": "半导体设备ETF国泰", +} +STATE_FILE = ROOT / "data" / "etf_alert_state.json" + + +@dataclass(frozen=True) +class AlertCandidate: + symbol: str + name: str + quote: dict[str, Any] + technical: dict[str, Any] + decision: dict[str, Any] + trigger_reason: str + + +def load_dotenv(path: Path = ROOT / ".env") -> None: + if not path.exists(): + return + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + os.environ.setdefault(key, value) + + +def market_is_open(now: datetime | None = None) -> bool: + current = now or datetime.now(CN_TZ) + if current.weekday() >= 5: + return False + t = current.time() + return dtime(9, 30) <= t <= dtime(11, 30) or dtime(13, 0) <= t <= dtime(15, 0) + + +def as_float(value: Any) -> float | None: + try: + if value is None or value == "": + return None + return float(value) + except (TypeError, ValueError): + return None + + +def pct_distance(price: float | None, level: float | None) -> float | None: + if price is None or level is None or level == 0: + return None + return (price - level) / level * 100 + + +async def fetch_quotes(symbols: list[str]) -> dict[str, dict[str, Any]]: + response = await get_quote_orchestrator().fetch( + ProviderRequest(symbols=tuple(symbols), market="CN"), + cache_ttl_sec=0, + ) + if not response.success: + raise RuntimeError(response.error or "quote provider failed") + return {str(item.get("symbol")): item for item in response.data or []} + + +def evaluate_symbol( + symbol: str, + fallback_name: str, + quote: dict[str, Any], + technical: dict[str, Any], +) -> AlertCandidate | None: + price = as_float(quote.get("current_price")) + support = as_float( + technical.get("support") or technical.get("support_m") or technical.get("support_s") + ) + resistance = as_float( + technical.get("resistance") + or technical.get("resistance_m") + or technical.get("resistance_s") + ) + decision = evaluate_a_share_decision( + { + "symbol": symbol, + "name": quote.get("name") or fallback_name, + "market": "CN", + "quote": quote, + "technical": technical, + "position": { + "has_position": False, + "stop_loss": round(support * 0.99, 3) if support else None, + "target_price": round(resistance, 3) if resistance else None, + "trading_style": "swing", + }, + } + ).to_dict() + + label = decision.get("label") + risk = decision.get("risk_level") + high = as_float(quote.get("high_price") or quote.get("high")) + rsi6 = as_float(technical.get("rsi6")) + kdj_j = as_float(technical.get("kdj_j")) + boll_status = str(technical.get("boll_status") or "") + trend = str(technical.get("trend") or "") + macd_status = str(technical.get("macd_status") or "") + change_5d = as_float(technical.get("change_5d")) + support_distance = pct_distance(price, support) + high_pullback = None + if price and high: + high_pullback = (high - price) / high * 100 + resistance_distance = None + if price and resistance: + resistance_distance = (resistance - price) / price * 100 + + hard_overheated = ( + (rsi6 is not None and rsi6 > 88) + or (kdj_j is not None and kdj_j > 118) + or ( + "突破上轨" in boll_status + and high_pullback is not None + and high_pullback < 2.5 + ) + or (support_distance is not None and support_distance > 28) + ) + if label == "买入候选" and not hard_overheated: + reason = "激进策略:规则标签进入买入候选,未触发硬性过热" + elif ( + label == "观察" + and support_distance is not None + and 0 <= support_distance <= 15 + and rsi6 is not None + and 32 <= rsi6 <= 78 + and (kdj_j is None or kdj_j < 105) + and not hard_overheated + ): + reason = f"激进策略:回踩到可接受区间,距支撑约 {support_distance:.1f}%" + elif ( + label == "观察" + and resistance_distance is not None + and resistance_distance >= 4 + and not hard_overheated + and ("金叉" in macd_status or trend == "多头排列") + ): + reason = f"激进策略:趋势仍在,上方压力空间约 {resistance_distance:.1f}%" + elif ( + label == "禁止追高" + and high_pullback is not None + and high_pullback >= 3 + and support_distance is not None + and support_distance <= 28 + and resistance_distance is not None + and resistance_distance >= 4 + and rsi6 is not None + and rsi6 <= 85 + and (kdj_j is None or kdj_j <= 112) + and (change_5d is None or change_5d <= 28) + ): + reason = ( + f"激进策略:强势标的盘中回落 {high_pullback:.1f}%," + f"压力空间约 {resistance_distance:.1f}%" + ) + else: + return None + + return AlertCandidate( + symbol=symbol, + name=quote.get("name") or fallback_name, + quote=quote, + technical=technical, + decision=decision, + trigger_reason=reason, + ) + + +def load_state() -> dict[str, Any]: + if not STATE_FILE.exists(): + return {} + try: + return json.loads(STATE_FILE.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return {} + + +def save_state(state: dict[str, Any]) -> None: + STATE_FILE.parent.mkdir(parents=True, exist_ok=True) + STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8") + + +def should_send(candidate: AlertCandidate, state: dict[str, Any]) -> bool: + today = date.today().isoformat() + key = f"{today}:{candidate.symbol}:{candidate.trigger_reason}" + return not state.get(key) + + +def mark_sent(candidate: AlertCandidate, state: dict[str, Any]) -> None: + today = date.today().isoformat() + key = f"{today}:{candidate.symbol}:{candidate.trigger_reason}" + state[key] = datetime.now(CN_TZ).isoformat(timespec="seconds") + save_state(state) + + +def smtp_config() -> dict[str, Any]: + to_addr = os.getenv("ETF_ALERT_TO", "").strip() + user = os.getenv("SMTP_USER", "").strip() + password = os.getenv("SMTP_PASSWORD", "").strip() + host = os.getenv("SMTP_HOST", "smtp.qq.com").strip() + port = int(os.getenv("SMTP_PORT", "465")) + from_addr = os.getenv("SMTP_FROM", user).strip() + missing = [ + name + for name, value in { + "ETF_ALERT_TO": to_addr, + "SMTP_USER": user, + "SMTP_PASSWORD": password, + "SMTP_FROM/SMTP_USER": from_addr, + }.items() + if not value + ] + if missing: + raise RuntimeError(f"missing email env: {', '.join(missing)}") + return { + "to": to_addr, + "user": user, + "password": password, + "host": host, + "port": port, + "from": from_addr, + } + + +def format_num(value: Any, digits: int = 2) -> str: + number = as_float(value) + if number is None: + return "-" + return f"{number:.{digits}f}" + + +def email_body(candidate: AlertCandidate) -> str: + q = candidate.quote + t = candidate.technical + d = candidate.decision + return "\n".join( + [ + f"{candidate.symbol} {candidate.name} 出现规则盯盘机会。", + "", + f"触发原因:{candidate.trigger_reason}", + f"当前价:{format_num(q.get('current_price'), 3)}", + f"涨跌幅:{format_num(q.get('change_pct'))}%", + f"规则标签:{d.get('label')},风险:{d.get('risk_level')},评分:{d.get('score')}", + f"支撑位:{format_num(t.get('support') or t.get('support_m') or t.get('support_s'), 3)}", + f"压力位:{format_num(t.get('resistance') or t.get('resistance_m') or t.get('resistance_s'), 3)}", + f"RSI6:{format_num(t.get('rsi6'), 1)},KDJ_J:{format_num(t.get('kdj_j'), 1)}", + f"MACD/趋势:{t.get('macd_status') or '-'} / {t.get('trend') or '-'}", + "", + "确认条件:", + *[f"- {item}" for item in d.get("confirm_conditions", [])], + "", + "失效条件:", + *[f"- {item}" for item in d.get("invalidation_conditions", [])], + "", + "仅为规则盯盘提醒,不构成投资建议。", + ] + ) + + +def send_email(candidate: AlertCandidate) -> None: + cfg = smtp_config() + msg = EmailMessage() + msg["Subject"] = f"A股ETF机会提醒:{candidate.symbol} {candidate.name}" + msg["From"] = cfg["from"] + msg["To"] = cfg["to"] + msg.set_content(email_body(candidate)) + + if cfg["port"] == 465: + with smtplib.SMTP_SSL(cfg["host"], cfg["port"], timeout=20) as server: + server.login(cfg["user"], cfg["password"]) + server.send_message(msg) + else: + with smtplib.SMTP(cfg["host"], cfg["port"], timeout=20) as server: + server.starttls() + server.login(cfg["user"], cfg["password"]) + server.send_message(msg) + + +def check_once(symbols: dict[str, str], dry_run: bool = False) -> list[AlertCandidate]: + quotes = asyncio.run(fetch_quotes(list(symbols))) + collector = KlineCollector(MarketCode.CN) + state = load_state() + sent: list[AlertCandidate] = [] + for symbol, fallback_name in symbols.items(): + quote = quotes.get(symbol) + if not quote: + print(f"[{datetime.now(CN_TZ).isoformat(timespec='seconds')}] no quote: {symbol}") + continue + technical = collector.get_kline_summary(symbol) + if technical.get("error"): + print(f"[{datetime.now(CN_TZ).isoformat(timespec='seconds')}] no kline: {symbol}") + continue + candidate = evaluate_symbol(symbol, fallback_name, quote, technical) + if not candidate: + label = evaluate_a_share_decision( + { + "symbol": symbol, + "name": quote.get("name") or fallback_name, + "market": "CN", + "quote": quote, + "technical": technical, + "position": {"has_position": False}, + } + ).to_dict()["label"] + print( + f"[{datetime.now(CN_TZ).isoformat(timespec='seconds')}] " + f"{symbol} no alert, label={label}" + ) + continue + if not should_send(candidate, state): + print( + f"[{datetime.now(CN_TZ).isoformat(timespec='seconds')}] " + f"{symbol} skipped duplicate: {candidate.trigger_reason}" + ) + continue + if dry_run: + print(email_body(candidate)) + else: + send_email(candidate) + mark_sent(candidate, state) + print( + f"[{datetime.now(CN_TZ).isoformat(timespec='seconds')}] " + f"sent alert: {symbol} {candidate.trigger_reason}" + ) + sent.append(candidate) + return sent + + +def parse_symbols(raw: str | None) -> dict[str, str]: + if not raw: + return DEFAULT_SYMBOLS + parsed: dict[str, str] = {} + for item in raw.split(","): + part = item.strip() + if not part: + continue + if ":" in part: + symbol, name = part.split(":", 1) + parsed[symbol.strip()] = name.strip() + else: + parsed[part] = DEFAULT_SYMBOLS.get(part, part) + return parsed or DEFAULT_SYMBOLS + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--interval", type=int, default=15, help="loop interval in seconds") + parser.add_argument("--once", action="store_true", help="run one check and exit") + parser.add_argument("--dry-run", action="store_true", help="print alerts instead of sending") + parser.add_argument( + "--ignore-market-hours", + action="store_true", + help="run even outside A-share trading hours", + ) + parser.add_argument( + "--symbols", + default=os.getenv("ETF_ALERT_SYMBOLS"), + help="comma list, e.g. 159995:芯片ETF华夏,159516:半导体设备ETF国泰", + ) + args = parser.parse_args() + + load_dotenv() + symbols = parse_symbols(args.symbols) + + while True: + if args.ignore_market_hours or market_is_open(): + try: + check_once(symbols, dry_run=args.dry_run) + except Exception as exc: + print(f"[{datetime.now(CN_TZ).isoformat(timespec='seconds')}] check failed: {exc}") + if args.once: + return 1 + else: + print(f"[{datetime.now(CN_TZ).isoformat(timespec='seconds')}] market closed") + if args.once: + return 0 + time.sleep(max(5, args.interval)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_etf_monitor.sh b/scripts/run_etf_monitor.sh new file mode 100755 index 0000000..3554eb1 --- /dev/null +++ b/scripts/run_etf_monitor.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +if [[ ! -d ".venv" ]]; then + echo "Missing .venv. Install Python dependencies before starting the monitor." >&2 + exit 1 +fi + +source .venv/bin/activate +exec python scripts/monitor_etf_alerts.py --interval "${ETF_ALERT_INTERVAL:-15}" diff --git a/src/core/signals/__init__.py b/src/core/signals/__init__.py index fea358e..ca6c0a2 100644 --- a/src/core/signals/__init__.py +++ b/src/core/signals/__init__.py @@ -1,3 +1,18 @@ +from .a_share_decision import ( + DecisionInput, + DecisionLabel, + DecisionResult, + PositionInput, + evaluate_a_share_decision, +) from .signal_pack import SignalPack, SignalPackBuilder -__all__ = ["SignalPack", "SignalPackBuilder"] +__all__ = [ + "DecisionInput", + "DecisionLabel", + "DecisionResult", + "PositionInput", + "SignalPack", + "SignalPackBuilder", + "evaluate_a_share_decision", +] diff --git a/src/core/signals/a_share_decision.py b/src/core/signals/a_share_decision.py new file mode 100644 index 0000000..d98e047 --- /dev/null +++ b/src/core/signals/a_share_decision.py @@ -0,0 +1,516 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from enum import Enum +from typing import Any + +from src.models.market import MarketCode, StockData + + +class DecisionLabel(str, Enum): + WATCH = "观察" + BUY_CANDIDATE = "买入候选" + HOLD = "继续持有" + ADD_WATCH = "加仓观察" + REDUCE_WARNING = "减仓警告" + STOP_LOSS = "止损触发" + NO_CHASE = "禁止追高" + + +@dataclass(frozen=True) +class PositionInput: + has_position: bool = False + avg_cost: float | None = None + quantity: float | None = None + stop_loss: float | None = None + target_price: float | None = None + max_position_ratio: float | None = None + current_position_ratio: float | None = None + trading_style: str = "swing" + + +@dataclass(frozen=True) +class DecisionInput: + symbol: str + name: str = "" + market: MarketCode = MarketCode.CN + quote: StockData | dict[str, Any] | None = None + technical: dict[str, Any] = field(default_factory=dict) + position: PositionInput | dict[str, Any] | None = None + news_flags: list[str] = field(default_factory=list) + sector_strength: float | None = None + already_no_chase_today: bool = False + + +@dataclass(frozen=True) +class DecisionResult: + symbol: str + name: str + market: str + label: DecisionLabel + score: int + reasons: list[str] + risks: list[str] + confirm_conditions: list[str] + invalidation_conditions: list[str] + risk_level: str + generated_at: str + + def to_dict(self) -> dict[str, Any]: + data = asdict(self) + data["label"] = self.label.value + return data + + +def evaluate_a_share_decision(data: DecisionInput | dict[str, Any]) -> DecisionResult: + """Evaluate an A-share watchlist/position into a human-confirmed decision label. + + This is intentionally deterministic. LLMs may explain the output later, but the + label and guardrails must come from structured quote, technical and position data. + """ + + inp = _coerce_input(data) + quote = _quote_dict(inp.quote) + tech = inp.technical if isinstance(inp.technical, dict) else {} + pos = _coerce_position(inp.position) + + reasons: list[str] = [] + risks: list[str] = [] + confirm: list[str] = [] + invalid: list[str] = [] + + price = _first_float(quote, "current_price", "price", "last_price") or _f( + tech.get("last_close") + ) + change_pct = _first_float(quote, "change_pct", "pct_chg") + high = _first_float(quote, "high_price", "high") + low = _first_float(quote, "low_price", "low") + turnover = _first_float(quote, "turnover", "amount") + volume_ratio = _first_float(tech, "volume_ratio") + + ma5 = _f(tech.get("ma5")) + ma10 = _f(tech.get("ma10")) + ma20 = _f(tech.get("ma20")) + support = _first_float(tech, "support", "support_m", "support_s") + resistance = _first_float(tech, "resistance", "resistance_m", "resistance_s") + rsi6 = _f(tech.get("rsi6")) + kdj_j = _f(tech.get("kdj_j")) + trend = str(tech.get("trend") or "") + macd_cross = str(tech.get("macd_cross") or tech.get("macd_status") or "") + boll_status = str(tech.get("boll_status") or "") + volume_trend = str(tech.get("volume_trend") or "") + + score = 50 + + if inp.market != MarketCode.CN: + risks.append("当前规则按 A 股交易特征设计,非 A 股仅作弱参考") + score -= 8 + + if _is_special_treatment(inp.symbol, inp.name): + return _result( + inp, + DecisionLabel.WATCH, + 15, + ["ST/退市风险标的不进入买入候选"], + ["特殊风险标的"], + ["仅人工复核,不触发买入"], + ["摘帽且重新满足趋势和风控条件"], + "高", + ) + + if pos.stop_loss is not None and price is not None and price <= pos.stop_loss: + reasons.append(f"当前价 {price:.2f} 触及止损价 {pos.stop_loss:.2f}") + invalid.append("重新站回止损位并修复原买入逻辑") + return _result( + inp, + DecisionLabel.STOP_LOSS, + 95, + reasons, + risks or ["已触发硬止损条件"], + ["人工确认是否按计划止损或减仓"], + invalid, + "高", + ) + + if price is not None and support is not None and _is_breakdown(price, support): + reasons.append(f"当前价跌破关键支撑 {support:.2f}") + score -= 20 + risks.append("关键支撑被破坏") + invalid.append("收盘重新站回关键支撑位") + if pos.has_position: + return _result( + inp, + DecisionLabel.STOP_LOSS if pos.stop_loss else DecisionLabel.REDUCE_WARNING, + 88 if pos.stop_loss else 78, + reasons, + risks, + ["人工确认是否减仓或止损"], + invalid, + "高", + ) + + no_chase_reasons = _no_chase_reasons( + change_pct=change_pct, + price=price, + high=high, + low=low, + support=support, + resistance=resistance, + rsi6=rsi6, + kdj_j=kdj_j, + boll_status=boll_status, + volume_ratio=volume_ratio, + already_no_chase_today=inp.already_no_chase_today, + ) + if no_chase_reasons: + if pos.has_position: + return _result( + inp, + DecisionLabel.REDUCE_WARNING, + 82, + no_chase_reasons, + risks + ["持仓接近压力位或动量过热,不适合追高加仓"], + ["人工确认是否减仓或停止加仓"], + ["回踩支撑后重新企稳,且动量过热解除"], + "高", + ) + return _result( + inp, + DecisionLabel.NO_CHASE, + 84, + no_chase_reasons, + risks + ["追价风险收益比不足"], + ["等待回踩确认或放弃本次信号"], + ["价格回到支撑附近且风险收益比重新达标"], + "高", + ) + + if trend == "多头排列": + score += 12 + reasons.append("均线多头排列") + elif trend == "空头排列": + score -= 16 + risks.append("均线空头排列") + elif trend == "均线交织": + score -= 3 + risks.append("均线交织,趋势确认度不足") + + if ma5 is not None and ma10 is not None and ma20 is not None: + if ma5 > ma10 and ma20 > 0 and (ma10 >= ma20 or abs(ma10 - ma20) / ma20 <= 0.015): + score += 8 + reasons.append("MA5 强于 MA10,MA20 未明显转弱") + if price is not None and price >= ma20: + score += 6 + reasons.append("价格位于 MA20 上方") + elif price is not None: + score -= 10 + risks.append("价格低于 MA20") + + if "金叉" in macd_cross: + score += 7 + reasons.append("MACD 金叉或偏多") + elif "死叉" in macd_cross: + score -= 9 + risks.append("MACD 死叉或偏空") + + if rsi6 is not None: + if 35 <= rsi6 <= 68: + score += 5 + reasons.append(f"RSI {rsi6:.1f} 处于可接受区间") + elif rsi6 > 72: + score -= 8 + risks.append(f"RSI {rsi6:.1f} 偏热") + elif rsi6 < 28: + score -= 4 + risks.append(f"RSI {rsi6:.1f} 偏弱,需等待修复") + + if volume_ratio is not None: + if 1.2 <= volume_ratio <= 2.8: + score += 7 + reasons.append(f"量比 {volume_ratio:.1f} 温和放大") + elif volume_ratio > 3.5: + score -= 5 + risks.append(f"量比 {volume_ratio:.1f} 过高,需防冲高回落") + elif volume_ratio < 0.7: + score -= 5 + risks.append("量能不足") + elif "放量" in volume_trend: + score += 3 + reasons.append("量能放大") + + rr = _risk_reward(price, support or pos.stop_loss, resistance or pos.target_price) + if rr is not None: + if rr >= 1.8: + score += 8 + reasons.append(f"风险收益比约 {rr:.1f}:1") + elif rr < 1.2: + score -= 12 + risks.append(f"风险收益比约 {rr:.1f}:1,空间不足") + + if inp.sector_strength is not None: + if inp.sector_strength >= 60: + score += 5 + reasons.append("板块强度同步偏强") + elif inp.sector_strength <= 40: + score -= 7 + risks.append("板块强度偏弱") + + for flag in inp.news_flags: + if flag: + score -= 10 + risks.append(f"消息风险:{flag}") + + if pos.has_position: + label = _position_label(score, price, pos, resistance, rsi6, volume_ratio, risks, reasons) + else: + label = _entry_label(score, pos, rr, risks) + + if label == DecisionLabel.BUY_CANDIDATE: + confirm.extend( + [ + "回踩不破关键支撑或 MA20", + "放量突破压力位后不快速回落", + "板块强度继续维持", + ] + ) + invalid.extend( + [ + "跌破 MA20 或关键支撑", + "放量长上影且无法收回", + "板块转弱或出现重大负面消息", + ] + ) + elif label == DecisionLabel.HOLD: + confirm.append("继续观察是否保持在成本价和止损位上方") + invalid.append("跌破止损位、MA20 或持仓保护线") + elif label == DecisionLabel.ADD_WATCH: + confirm.extend(["只在回踩支撑不破时人工确认", "不得突破后直接追高加仓"]) + invalid.append("放量滞涨或跌回 MA10/MA20 下方") + elif label == DecisionLabel.REDUCE_WARNING: + confirm.append("人工确认是否按仓位纪律减仓") + invalid.append("重新站回压力位并恢复量价配合") + else: + confirm.append("继续观察,不触发交易动作") + invalid.append("等待趋势、量能和风险收益比同时改善") + + return _result( + inp, + label, + _clamp_int(score, 0, 100), + _dedupe(reasons) or ["结构化条件不足,保持观察"], + _dedupe(risks), + _dedupe(confirm), + _dedupe(invalid), + _risk_level(label, score, risks), + ) + + +def _entry_label( + score: int, pos: PositionInput, rr: float | None, risks: list[str] +) -> DecisionLabel: + if pos.stop_loss is None: + risks.append("未设置止损位,不允许进入买入候选") + return DecisionLabel.WATCH + if rr is None or rr < 1.8: + return DecisionLabel.WATCH + if score >= 72: + return DecisionLabel.BUY_CANDIDATE + return DecisionLabel.WATCH + + +def _position_label( + score: int, + price: float | None, + pos: PositionInput, + resistance: float | None, + rsi6: float | None, + volume_ratio: float | None, + risks: list[str], + reasons: list[str], +) -> DecisionLabel: + pnl_pct = None + if price is not None and pos.avg_cost: + pnl_pct = (price - pos.avg_cost) / pos.avg_cost * 100 + if pnl_pct >= 8: + reasons.append(f"持仓浮盈约 {pnl_pct:.1f}%") + elif pnl_pct <= -4: + risks.append(f"持仓浮亏约 {pnl_pct:.1f}%") + + if resistance and price and price >= resistance * 0.985 and (rsi6 or 0) >= 68: + risks.append("接近压力位且动量偏热") + return DecisionLabel.REDUCE_WARNING + if volume_ratio is not None and volume_ratio >= 3.5 and (rsi6 or 0) >= 70: + risks.append("放量过热,需防冲高回落") + return DecisionLabel.REDUCE_WARNING + if score >= 76 and pnl_pct is not None and pnl_pct > 0: + return DecisionLabel.ADD_WATCH + if score >= 55: + return DecisionLabel.HOLD + return DecisionLabel.REDUCE_WARNING + + +def _no_chase_reasons(**kwargs: Any) -> list[str]: + reasons: list[str] = [] + if kwargs["already_no_chase_today"]: + reasons.append("当日已触发禁止追高") + change_pct = kwargs["change_pct"] + price = kwargs["price"] + high = kwargs["high"] + low = kwargs["low"] + support = kwargs["support"] + resistance = kwargs["resistance"] + rsi6 = kwargs["rsi6"] + kdj_j = kwargs["kdj_j"] + boll_status = kwargs["boll_status"] + volume_ratio = kwargs["volume_ratio"] + + if change_pct is not None and change_pct >= 7: + reasons.append(f"当日涨幅 {change_pct:.1f}% 过高") + if price is not None and support is not None and support > 0: + distance = (price - support) / support * 100 + if distance >= 9: + reasons.append(f"距离支撑位约 {distance:.1f}%") + if price is not None and resistance is not None and resistance > 0: + room = (resistance - price) / price * 100 + if 0 <= room <= 3: + reasons.append("上方压力位距离过近") + if rsi6 is not None and rsi6 >= 78: + reasons.append(f"RSI {rsi6:.1f} 严重偏热") + if kdj_j is not None and kdj_j >= 105: + reasons.append(f"KDJ J值 {kdj_j:.1f} 偏热") + if "突破上轨" in boll_status and volume_ratio is not None and volume_ratio > 3: + reasons.append("布林上轨外放量,追高回撤风险高") + if high is not None and low is not None and price is not None and high > low: + close_position = (price - low) / (high - low) + if close_position < 0.45 and volume_ratio is not None and volume_ratio > 2.5: + reasons.append("放量冲高后收盘位置偏低") + return reasons + + +def _risk_reward( + price: float | None, downside_ref: float | None, upside_ref: float | None +) -> float | None: + if price is None or downside_ref is None or upside_ref is None: + return None + downside = price - downside_ref + upside = upside_ref - price + if downside <= 0 or upside <= 0: + return None + return round(upside / downside, 2) + + +def _coerce_input(data: DecisionInput | dict[str, Any]) -> DecisionInput: + if isinstance(data, DecisionInput): + return data + raw = dict(data or {}) + market = raw.get("market") or MarketCode.CN + if not isinstance(market, MarketCode): + market = MarketCode(str(market)) + return DecisionInput( + symbol=str(raw.get("symbol") or ""), + name=str(raw.get("name") or ""), + market=market, + quote=raw.get("quote"), + technical=raw.get("technical") or {}, + position=raw.get("position"), + news_flags=list(raw.get("news_flags") or []), + sector_strength=_f(raw.get("sector_strength")), + already_no_chase_today=bool(raw.get("already_no_chase_today")), + ) + + +def _coerce_position(data: PositionInput | dict[str, Any] | None) -> PositionInput: + if isinstance(data, PositionInput): + return data + raw = data if isinstance(data, dict) else {} + return PositionInput( + has_position=bool(raw.get("has_position")), + avg_cost=_f(raw.get("avg_cost")), + quantity=_f(raw.get("quantity")), + stop_loss=_first_float(raw, "stop_loss", "stop_loss_price"), + target_price=_f(raw.get("target_price")), + max_position_ratio=_f(raw.get("max_position_ratio")), + current_position_ratio=_f(raw.get("current_position_ratio")), + trading_style=str(raw.get("trading_style") or "swing"), + ) + + +def _quote_dict(quote: StockData | dict[str, Any] | None) -> dict[str, Any]: + if isinstance(quote, StockData): + return asdict(quote) + return quote if isinstance(quote, dict) else {} + + +def _result( + inp: DecisionInput, + label: DecisionLabel, + score: int, + reasons: list[str], + risks: list[str], + confirm: list[str], + invalid: list[str], + risk_level: str, +) -> DecisionResult: + return DecisionResult( + symbol=inp.symbol, + name=inp.name, + market=inp.market.value, + label=label, + score=_clamp_int(score, 0, 100), + reasons=_dedupe(reasons), + risks=_dedupe(risks), + confirm_conditions=_dedupe(confirm), + invalidation_conditions=_dedupe(invalid), + risk_level=risk_level, + generated_at=datetime.now(timezone.utc).isoformat(timespec="seconds"), + ) + + +def _risk_level(label: DecisionLabel, score: int, risks: list[str]) -> str: + if label in {DecisionLabel.STOP_LOSS, DecisionLabel.NO_CHASE, DecisionLabel.REDUCE_WARNING}: + return "高" + if risks or score < 65: + return "中" + return "低" + + +def _is_special_treatment(symbol: str, name: str) -> bool: + text = f"{symbol} {name}".upper() + return "ST" in text or "退" in text + + +def _is_breakdown(price: float, support: float) -> bool: + return support > 0 and price < support * 0.985 + + +def _first_float(data: dict[str, Any], *keys: str) -> float | None: + for key in keys: + value = _f(data.get(key)) + if value is not None: + return value + return None + + +def _f(value: Any) -> float | None: + try: + if value is None or value == "": + return None + return float(value) + except (TypeError, ValueError): + return None + + +def _clamp_int(value: float | int, low: int, high: int) -> int: + return max(low, min(high, int(round(value)))) + + +def _dedupe(items: list[str]) -> list[str]: + out: list[str] = [] + seen: set[str] = set() + for item in items: + text = str(item or "").strip() + if text and text not in seen: + out.append(text) + seen.add(text) + return out diff --git a/src/web/api/watchlist.py b/src/web/api/watchlist.py new file mode 100644 index 0000000..fddeec3 --- /dev/null +++ b/src/web/api/watchlist.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter +from pydantic import BaseModel, Field + +from src.core.signals.a_share_decision import evaluate_a_share_decision + +router = APIRouter() + + +class WatchlistSignalRequest(BaseModel): + symbol: str + name: str = "" + market: str = "CN" + quote: dict[str, Any] = Field(default_factory=dict) + technical: dict[str, Any] = Field(default_factory=dict) + position: dict[str, Any] = Field(default_factory=dict) + news_flags: list[str] = Field(default_factory=list) + sector_strength: float | None = None + already_no_chase_today: bool = False + + +@router.post("/signals/evaluate") +def evaluate_signal(req: WatchlistSignalRequest) -> dict[str, Any]: + """Evaluate one A-share watchlist item with deterministic guardrails.""" + + return evaluate_a_share_decision(req.model_dump()).to_dict() + diff --git a/src/web/app.py b/src/web/app.py index 5f528e3..af80a9b 100644 --- a/src/web/app.py +++ b/src/web/app.py @@ -26,6 +26,7 @@ dashboard, paper_trading, chat, + watchlist, ) from src.web.api import factors from src.web.api import health @@ -171,6 +172,12 @@ tags=["chat"], dependencies=protected, ) +app.include_router( + watchlist.router, + prefix="/api/watchlist", + tags=["watchlist"], + dependencies=protected, +) @app.get("/api/health") diff --git a/src/web/stock_list.py b/src/web/stock_list.py index 2d20bb6..3f09d50 100644 --- a/src/web/stock_list.py +++ b/src/web/stock_list.py @@ -352,19 +352,27 @@ def _normalize_symbol(code: str, mkt: str) -> str: code_raw = (item.get("Code") or "").strip().upper() # 判断市场 - if ( + is_cn_stock_or_fund = ( classify in ("AStock", "BJStock") or any(ch in security_type for ch in ("沪", "深", "北")) or code_raw.endswith(".BJ") or code_raw.startswith("BJ") - ): + ) + is_cn_etf_or_fund = ( + classify in ("Fund", "ETF", "LOF") + or any(word in security_type.upper() for word in ("ETF", "LOF", "基金")) + or code_raw.startswith(("SH5", "SZ1", "SZ15", "SZ16", "SZ18")) + or code_raw[:1] in ("5", "1") + ) + + if is_cn_stock_or_fund or is_cn_etf_or_fund: stock_market = "CN" elif classify == "HKStock" or "港" in security_type: stock_market = "HK" elif classify == "UsStock" or "美" in security_type: stock_market = "US" else: - continue # 跳过其他类型(债券、基金等) + continue # 跳过债券等暂不支持品种 # 市场筛选 if market and stock_market != market: @@ -372,7 +380,7 @@ def _normalize_symbol(code: str, mkt: str) -> str: # 只保留股票(排除债券等) type_us = item.get("TypeUS", "") - if stock_market == "US" and type_us and type_us not in ("1", "2", "3"): # 1=普通股, 3=ADR/ADS 等;5=ETF 等 + if stock_market == "US" and type_us and type_us not in ("1", "2", "3", "5"): # 1=普通股, 3=ADR/ADS, 5=ETF 等 continue code = item.get("Code", "") diff --git a/tests/test_a_share_decision.py b/tests/test_a_share_decision.py new file mode 100644 index 0000000..b4d0c04 --- /dev/null +++ b/tests/test_a_share_decision.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from src.core.signals.a_share_decision import DecisionLabel, evaluate_a_share_decision +from src.web.api.watchlist import router + + +def _base_input(**overrides): + data = { + "symbol": "600519", + "name": "贵州茅台", + "market": "CN", + "quote": { + "current_price": 102.0, + "change_pct": 2.4, + "high_price": 103.0, + "low_price": 99.0, + "turnover": 1_200_000_000, + }, + "technical": { + "trend": "多头排列", + "ma5": 101.0, + "ma10": 100.0, + "ma20": 98.0, + "macd_cross": "金叉", + "rsi6": 58.0, + "kdj_j": 72.0, + "volume_ratio": 1.8, + "support": 98.0, + "resistance": 112.0, + "boll_status": "正常波动", + }, + "position": { + "has_position": False, + "stop_loss": 97.0, + "target_price": 112.0, + }, + "sector_strength": 66.0, + } + data.update(overrides) + return data + + +def test_buy_candidate_requires_structured_positive_conditions(): + """多项正向结构化条件满足时输出买入候选。""" + result = evaluate_a_share_decision(_base_input()) + + assert result.label == DecisionLabel.BUY_CANDIDATE + assert result.score >= 72 + assert "跌破 MA20 或关键支撑" in result.invalidation_conditions + assert any("风险收益比" in item for item in result.reasons) + + +def test_missing_stop_loss_blocks_buy_candidate(): + """没有止损位时即使技术面偏强也不得输出买入候选。""" + data = _base_input(position={"has_position": False, "target_price": 112.0}) + + result = evaluate_a_share_decision(data) + + assert result.label == DecisionLabel.WATCH + assert any("未设置止损位" in item for item in result.risks) + + +def test_no_chase_overrides_buy_candidate(): + """涨幅过高或距离支撑过远时禁止追高优先于买入候选。""" + data = _base_input( + quote={ + "current_price": 111.0, + "change_pct": 8.2, + "high_price": 112.0, + "low_price": 103.0, + }, + technical={ + **_base_input()["technical"], + "rsi6": 82.0, + "kdj_j": 108.0, + "support": 98.0, + "resistance": 113.0, + }, + ) + + result = evaluate_a_share_decision(data) + + assert result.label == DecisionLabel.NO_CHASE + assert result.risk_level == "高" + assert any("涨幅" in item for item in result.reasons) + + +def test_stop_loss_has_highest_priority_for_position(): + """当前价触及止损价时直接输出止损触发。""" + data = _base_input( + quote={"current_price": 95.5, "change_pct": -4.3}, + position={"has_position": True, "avg_cost": 101.0, "quantity": 100, "stop_loss": 96.0}, + ) + + result = evaluate_a_share_decision(data) + + assert result.label == DecisionLabel.STOP_LOSS + assert result.score >= 90 + assert result.risk_level == "高" + + +def test_position_near_resistance_and_overheated_warns_reduce(): + """持仓接近压力位且动量过热时输出减仓警告。""" + data = _base_input( + quote={"current_price": 111.2, "change_pct": 5.4, "high_price": 112.0, "low_price": 105.0}, + position={"has_position": True, "avg_cost": 98.0, "quantity": 100, "stop_loss": 96.0}, + technical={**_base_input()["technical"], "rsi6": 70.0, "resistance": 112.0}, + ) + + result = evaluate_a_share_decision(data) + + assert result.label == DecisionLabel.REDUCE_WARNING + assert any("压力位" in item or "过热" in item for item in result.risks) + + +def test_watchlist_router_mounted_shape(): + """watchlist API 路由暴露手动评估入口并返回规则标签。""" + paths = {route.path for route in router.routes} + + assert "/signals/evaluate" in paths + + app = FastAPI() + app.include_router(router, prefix="/api/watchlist") + client = TestClient(app) + response = client.post("/api/watchlist/signals/evaluate", json=_base_input()) + + assert response.status_code == 200 + assert response.json()["label"] == "买入候选"