├── .env
├── .eslintrc.json
├── .gitignore
├── README.md
├── next.config.mjs
├── package-lock.json
├── package.json
├── postcss.config.js
├── public
├── config.js
├── next.svg
└── vercel.svg
├── src
├── app
│ ├── data
│ │ ├── page.tsx
│ │ └── useSchemas.ts
│ ├── demo
│ │ └── page.tsx
│ ├── favicon.ico
│ ├── globals.css
│ ├── layout.tsx
│ ├── login
│ │ └── page.tsx
│ ├── page.tsx
│ ├── trade
│ │ ├── TagsDialog.tsx
│ │ ├── analysis
│ │ │ ├── NewsAnalysises.tsx
│ │ │ ├── NewsAnalysisesRealTime.tsx
│ │ │ └── NewsAnalysisesStats.tsx
│ │ ├── page.tsx
│ │ ├── stock-detail
│ │ │ ├── Events.tsx
│ │ │ ├── News.tsx
│ │ │ ├── SelectTagTypeDialog.tsx
│ │ │ ├── StockChart.tsx
│ │ │ ├── StockChartKline.tsx
│ │ │ ├── StockChartTs.tsx
│ │ │ ├── StockDetail.tsx
│ │ │ └── TagUpdateDialog.tsx
│ │ ├── stock-list
│ │ │ ├── Blink.tsx
│ │ │ ├── BuyDialog.tsx
│ │ │ ├── SellDialog.tsx
│ │ │ ├── SortCell.tsx
│ │ │ └── StockList.tsx
│ │ └── useData.tsx
│ └── workspace
│ │ ├── page.tsx
│ │ ├── stock
│ │ ├── CandlestickChart.tsx
│ │ ├── page.tsx
│ │ └── useData.tsx
│ │ ├── stock_tag
│ │ ├── TagInfoDialog.tsx
│ │ ├── page.tsx
│ │ └── useData.ts
│ │ └── useData.ts
├── components
│ ├── Dialog
│ │ ├── Confirm.tsx
│ │ ├── Info.tsx
│ │ ├── index.ts
│ │ ├── useConfirmDialog.tsx
│ │ └── useDialog.tsx
│ ├── Loading
│ │ └── index.tsx
│ ├── ThemeRegistry
│ │ ├── EmotionCache.tsx
│ │ ├── ThemeRegistry.tsx
│ │ └── theme.ts
│ └── layout
│ │ ├── ConditionalLayout.tsx
│ │ └── Header.tsx
├── hooks
│ └── useAuth.ts
├── interfaces
│ └── index.ts
├── services
│ ├── http.ts
│ └── index.ts
└── utils
│ └── index.ts
├── tailwind.config.ts
├── tsconfig.json
├── typings.d.ts
└── yarn.lock
/.env:
--------------------------------------------------------------------------------
1 | # NEXT_PUBLIC_SERVER = http://10.1.16.108:8090
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
2 |
3 | ## Environmental requirement
4 |
5 | - nodejs v18.17.0+
6 |
7 | ## Getting Started
8 |
9 | First, run the development server:
10 |
11 | ```bash
12 | npm run dev
13 | # or
14 | yarn dev
15 | # or
16 | pnpm dev
17 | # or
18 | bun dev
19 | ```
20 |
21 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
22 |
23 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
24 |
25 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
26 |
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | output: 'export',
4 | webpack: (config) => {
5 | return config;
6 | },
7 | };
8 |
9 | export default nextConfig;
10 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "zvt-ui",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@emotion/react": "^11.11.4",
13 | "@emotion/styled": "^11.11.0",
14 | "@fontsource/inter": "^5.0.17",
15 | "@mui/icons-material": "^5.15.14",
16 | "@mui/joy": "^5.0.0-beta.32",
17 | "ahooks": "^3.7.11",
18 | "axios": "^1.6.8",
19 | "classnames": "^2.5.1",
20 | "dayjs": "^1.11.11",
21 | "echarts": "^5.5.0",
22 | "echarts-for-react": "^3.0.2",
23 | "framer-motion": "^11.2.10",
24 | "klinecharts": "^9.8.10",
25 | "next": "14.1.4",
26 | "qs": "^6.12.0",
27 | "react": "^18",
28 | "react-dom": "^18",
29 | "react-icons": "^5.0.1",
30 | "tslib": "^2.6.2"
31 | },
32 | "devDependencies": {
33 | "@types/node": "^20",
34 | "@types/qs": "^6.9.14",
35 | "@types/react": "^18",
36 | "@types/react-dom": "^18",
37 | "autoprefixer": "^10.0.1",
38 | "eslint": "^8",
39 | "eslint-config-next": "14.1.4",
40 | "postcss": "^8",
41 | "tailwindcss": "^3.3.0",
42 | "typescript": "^5"
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/public/config.js:
--------------------------------------------------------------------------------
1 | window.SERVER_HOST = '';
2 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/app/data/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Skeleton, Table, Card, Select, Option, Stack, Button } from '@mui/joy';
4 | import useSchemas from './useSchemas';
5 | import Loading from '@/components/Loading';
6 |
7 | export default function Data() {
8 | const { providers, schemas, datas, loading, changeProvider, changeSchema } =
9 | useSchemas();
10 |
11 | const columns = Object.keys(datas?.[0] || {});
12 |
13 | return (
14 |
15 |
16 |
17 |
30 |
43 |
44 |
45 |
46 |
47 | {columns.length === 0 && (
48 | 暂无数据
49 | )}
50 |
51 |
52 | {columns.map((col) => (
53 | {col} |
54 | ))}
55 |
56 |
57 | {datas.map((row, index) => {
58 | return (
59 |
60 | {columns.map((col, cIndex) => (
61 | {row[col]} |
62 | ))}
63 |
64 | );
65 | })}
66 |
67 |
68 |
69 |
70 |
71 | );
72 | }
73 |
--------------------------------------------------------------------------------
/src/app/data/useSchemas.ts:
--------------------------------------------------------------------------------
1 | import { useAsyncEffect, useSetState } from 'ahooks';
2 | import { useEffect, useState } from 'react';
3 | import services from '@/services';
4 |
5 | export default function useSchemas() {
6 | const [loading, setLoading] = useState(false);
7 | const [providers, setProviders] = useSetState({
8 | data: [],
9 | current: '',
10 | });
11 | const [schemas, setSchemas] = useSetState({
12 | data: [],
13 | current: '',
14 | });
15 |
16 | const [datas, setDatas] = useState[]>([]);
17 |
18 | const updateDatas = async (provider: string, schema: string) => {
19 | const data = await services.getQueryData({ provider, schema });
20 | setDatas(data);
21 | };
22 |
23 | const updateSchemas = async (provider: string) => {
24 | const data = await services.getSchemas({ provider });
25 | const currentSchema = data?.[0];
26 | setSchemas({
27 | data: data,
28 | current: currentSchema,
29 | });
30 | updateDatas(provider, currentSchema);
31 | };
32 |
33 | useAsyncEffect(async () => {
34 | setLoading(true);
35 | const data = await services.getProviders();
36 | const currentProvider = data?.[0];
37 | updateSchemas(currentProvider);
38 | setProviders({
39 | data: data,
40 | current: currentProvider,
41 | });
42 | setLoading(false);
43 | }, []);
44 |
45 | return {
46 | loading,
47 | providers,
48 | schemas,
49 | datas,
50 | changeProvider: async (provider: string) => {
51 | setLoading(true);
52 | setProviders({ current: provider });
53 | await updateSchemas(provider);
54 | setLoading(false);
55 | },
56 | changeSchema: (schema: string) => {
57 | setLoading(true);
58 | setSchemas({ current: schema });
59 | updateDatas(providers.current, schema);
60 | setLoading(false);
61 | },
62 | };
63 | }
64 |
--------------------------------------------------------------------------------
/src/app/demo/page.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 |
3 | export default function Home() {
4 | return (
5 |
6 |
7 |
8 | Get started by editing
9 | src/app/page.tsx
10 |
11 |
29 |
30 |
31 |
32 |
40 |
41 |
42 |
111 |
112 | );
113 | }
114 |
--------------------------------------------------------------------------------
/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zvtvz/zvt_ui/14555986781baa91ac67b9e18ba8ad653e8ecc0c/src/app/favicon.ico
--------------------------------------------------------------------------------
/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | :root {
6 | --foreground-rgb: 0, 0, 0;
7 | --background-start-rgb: 214, 219, 220;
8 | --background-end-rgb: 255, 255, 255;
9 | }
10 |
11 | @media (prefers-color-scheme: dark) {
12 | :root {
13 | --foreground-rgb: 255, 255, 255;
14 | --background-start-rgb: 0, 0, 0;
15 | --background-end-rgb: 0, 0, 0;
16 | }
17 | }
18 |
19 | body {
20 | color: rgb(var(--foreground-rgb));
21 | background-color: #f8f8f8 !important;
22 | font-size: 14px;
23 | /* background: radial-gradient(
24 | farthest-corner circle at 0% 0%,
25 | #f3f6f9 0%,
26 | #ebf5ff 100%
27 | ); */
28 | }
29 |
30 | @layer utilities {
31 | .text-balance {
32 | text-wrap: balance;
33 | }
34 | }
35 |
36 | li::marker {
37 | margin-inline-end: 6px !important;
38 | }
39 |
40 | .text-right > input {
41 | text-align: right;
42 | }
43 |
44 | .header {
45 | box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.08);
46 | }
47 |
48 | .joy-pvuyop-JoyCard-root {
49 | background-color: #fff !important;
50 | }
51 |
52 | .w-container {
53 | max-width: 1600px;
54 | min-width: 1366px;
55 | }
56 |
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata, Viewport } from 'next';
2 | import Script from 'next/script';
3 |
4 | import ThemeRegistry from '@/components/ThemeRegistry/ThemeRegistry';
5 | import ConditionalLayout from '@/components/layout/ConditionalLayout';
6 |
7 | import './globals.css';
8 |
9 | export const metadata: Metadata = {
10 | title: 'zvt-ui',
11 | description: 'Generated by create next app',
12 | };
13 |
14 | export const viewport: Viewport = {
15 | width: 'device-width',
16 | initialScale: 1,
17 | };
18 |
19 | export default function RootLayout({
20 | children,
21 | }: Readonly<{
22 | children: React.ReactNode;
23 | }>) {
24 | return (
25 |
26 |
27 |
28 |
29 | {children}
30 | {/* */}
33 |
34 |
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/src/app/login/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState } from 'react';
4 | import { useRouter } from 'next/navigation';
5 | import {
6 | Box,
7 | Button,
8 | Card,
9 | CardContent,
10 | FormControl,
11 | FormLabel,
12 | Input,
13 | Stack,
14 | Typography,
15 | Alert,
16 | } from '@mui/joy';
17 | import { FiUser, FiLock } from 'react-icons/fi';
18 | import { AuthService } from '@/services/auth';
19 | import { useAuth } from '@/hooks/useAuth';
20 | import services from '@/services';
21 |
22 | export default function LoginPage() {
23 | const [username, setUsername] = useState('');
24 | const [password, setPassword] = useState('');
25 | const [loading, setLoading] = useState(false);
26 | const [error, setError] = useState('');
27 | const router = useRouter();
28 |
29 | // 使用认证检查
30 | const { isLoading: authLoading } = useAuth();
31 |
32 | const handleSubmit = async (e: React.FormEvent) => {
33 | e.preventDefault();
34 | setError('');
35 |
36 | if (!username.trim() || !password.trim()) {
37 | setError('请输入用户名和密码');
38 | return;
39 | }
40 |
41 | setLoading(true);
42 |
43 | try {
44 | // 调用真实的登录API
45 | await services.login({
46 | grant_type: 'password',
47 | username,
48 | password,
49 | });
50 |
51 | // 登录成功后跳转到交易页面
52 | router.push('/trade');
53 | } catch (err) {
54 | setError(err instanceof Error ? err.message : '登录失败,请重试');
55 | } finally {
56 | setLoading(false);
57 | }
58 | };
59 |
60 | // 认证检查加载中
61 | if (authLoading) {
62 | return (
63 |
72 |
73 |
89 |
93 | 检查登录状态...
94 |
95 |
96 |
97 | );
98 | }
99 |
100 | return (
101 |
113 |
119 | {/* 品牌标题 */}
120 |
121 | {/* 登录卡片 */}
122 |
131 |
132 |
133 |
141 | ZVT UI
142 |
143 |
149 | 请输入您的登录信息
150 |
151 |
152 |
153 | {error && (
154 |
163 | {error}
164 |
165 | )}
166 |
167 |
288 |
289 |
290 |
291 | {/* 版权信息 */}
292 |
300 | © 2025 ZVT UI. 保留所有权利。
301 |
302 |
303 |
304 | );
305 | }
306 |
--------------------------------------------------------------------------------
/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from 'next/navigation';
2 |
3 | export default function Home() {
4 | redirect(`/trade`);
5 |
6 | return (
7 |
8 |
11 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/src/app/trade/TagsDialog.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Modal,
3 | ModalDialog,
4 | DialogTitle,
5 | DialogContent,
6 | Stack,
7 | FormControl,
8 | Button,
9 | Checkbox,
10 | ModalClose,
11 | Typography,
12 | } from '@mui/joy';
13 |
14 | type Props = {
15 | open: boolean;
16 | onSubmit: (data: any) => void;
17 | globalTags: any[];
18 | checkedTags: any[];
19 | onCancel: () => void;
20 | };
21 |
22 | export default function TagsDialog({
23 | open,
24 | globalTags,
25 | checkedTags,
26 | onSubmit,
27 | onCancel,
28 | }: Props) {
29 | return (
30 |
31 |
32 |
33 | 修改标签
34 |
68 |
69 |
70 | );
71 | }
72 |
--------------------------------------------------------------------------------
/src/app/trade/analysis/NewsAnalysises.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Card,
3 | CardContent,
4 | Tab,
5 | TabList,
6 | TabPanel,
7 | Tabs,
8 | tabClasses,
9 | } from '@mui/joy';
10 | import NewsAnalysisesRealTime from './NewsAnalysisesRealTime';
11 | import NewsAnalysisesStats from './NewsAnalysisesStats';
12 | import { useEffect } from 'react';
13 |
14 | export default function NewsAnalysises({ dialog }: { dialog: any }) {
15 | return (
16 |
17 |
18 |
30 |
53 |
54 | AI实时分析
55 |
56 |
57 | AI分析汇总
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 | );
70 | }
71 |
--------------------------------------------------------------------------------
/src/app/trade/analysis/NewsAnalysisesRealTime.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Button,
3 | Card,
4 | CardContent,
5 | Chip,
6 | Tab,
7 | TabList,
8 | TabPanel,
9 | Tabs,
10 | Tooltip,
11 | Typography,
12 | tabClasses,
13 | } from '@mui/joy';
14 | import { useEffect, useRef, useState } from 'react';
15 | import services from '@/services';
16 | import dayjs from 'dayjs';
17 | import { useSetState, useUnmountedRef } from 'ahooks';
18 | import { getSimpleId } from '@/utils';
19 |
20 | const tagTypeText = {
21 | main_tag: '更新主标签',
22 | sub_tag: '更新次标签',
23 | new_tag: '待处理标签',
24 | } as any;
25 |
26 | export default function NewsAnalysises({ dialog }: { dialog: any }) {
27 | const [newsAnalysises, setNewsAnalysises] = useState([]);
28 | const [loading, setLoading] = useSetState>({});
29 | const unmountedRef = useUnmountedRef();
30 |
31 | const batchUpdateTags = async (analysis: any, suggestion: any) => {
32 | setLoading({
33 | [suggestion.id]: true,
34 | });
35 |
36 | try {
37 | await services.batchUpdateStockTags({
38 | entity_ids: suggestion.stocks.map((s: any) => s.entity_id),
39 | tag: suggestion.tag,
40 | tag_type: suggestion.tag_type,
41 | tag_reason: analysis.news_title,
42 | });
43 | dialog.show({
44 | title: '更新标签成功',
45 | });
46 | } finally {
47 | setLoading({
48 | [suggestion.id]: false,
49 | });
50 | }
51 | };
52 |
53 | useEffect(() => {
54 | const poolLoadNews = async () => {
55 | if (unmountedRef.current) return;
56 | try {
57 | const data = await services.getNewsAnalysis();
58 | data.forEach((item: any) => {
59 | item.news_analysis.tag_suggestions.up?.forEach((x: any) => {
60 | x.id = getSimpleId();
61 | });
62 | item.news_analysis.tag_suggestions.down?.forEach((x: any) => {
63 | x.id = getSimpleId();
64 | });
65 | });
66 | setNewsAnalysises(data);
67 | } finally {
68 | setTimeout(() => {
69 | poolLoadNews();
70 | }, 5000);
71 | }
72 | };
73 |
74 | poolLoadNews();
75 | }, []);
76 |
77 | return (
78 |
79 | {newsAnalysises?.map((analysis: any, index: number) => {
80 | return (
81 |
82 |
83 |
86 | {analysis.news_content || analysis.news_title}
87 |
88 | }
89 | variant="solid"
90 | >
91 |
92 |
93 | {dayjs(analysis.timestamp).format('YYYY-MM-DD')}
94 |
95 | {analysis.news_title || ''}
96 |
97 |
98 |
99 |
100 | {analysis.news_analysis?.tag_suggestions?.up?.map(
101 | (suggestion: any, idx: number) => {
102 | return (
103 |
107 |
108 |
115 | {suggestion.tag}
116 |
117 |
118 | {suggestion.stocks
119 | .map((s: any, idx: number) => s.name)
120 | .join('、')}
121 |
122 |
123 |
124 |
134 |
135 |
136 | );
137 | }
138 | )}
139 | {analysis.news_analysis?.tag_suggestions?.down?.map(
140 | (suggestion: any, index: number) => {
141 | return (
142 |
146 |
147 |
154 | {suggestion.tag}
155 |
156 |
157 | {suggestion.stocks
158 | .map((s: any, index: number) => s.name)
159 | .join('、')}
160 |
161 |
162 |
163 |
173 |
174 |
175 | );
176 | }
177 | )}
178 |
179 |
180 | );
181 | })}
182 |
183 | );
184 | }
185 |
--------------------------------------------------------------------------------
/src/app/trade/analysis/NewsAnalysisesStats.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Button,
3 | Card,
4 | CardContent,
5 | Chip,
6 | Tab,
7 | TabList,
8 | TabPanel,
9 | Tabs,
10 | Tooltip,
11 | Typography,
12 | tabClasses,
13 | } from '@mui/joy';
14 | import { useEffect, useState } from 'react';
15 | import services from '@/services';
16 | import dayjs from 'dayjs';
17 | import { useSetState } from 'ahooks';
18 |
19 | const tagTypeText = {
20 | main_tag: '更新主标签',
21 | sub_tag: '更新次标签',
22 | new_tag: '待处理标签',
23 | } as any;
24 |
25 | export default function NewsAnalysisesStats({ dialog }: { dialog: any }) {
26 | const [newsAnalysisesStats, setNewsAnalysisesStats] = useState([]);
27 | const [loading, setLoading] = useSetState>({});
28 |
29 | const batchUpdateTags = async (suggestion: any) => {
30 | setLoading({
31 | [suggestion.tag]: true,
32 | });
33 | try {
34 | await services.batchUpdateStockTags({
35 | entity_ids: suggestion.entity_ids,
36 | tag: suggestion.tag,
37 | tag_type: suggestion.tag_type,
38 | tag_reason: suggestion.tag + '_消息刺激',
39 | });
40 | dialog.show({
41 | title: '更新标签成功',
42 | });
43 | } finally {
44 | setLoading({ [suggestion.tag]: false });
45 | }
46 | };
47 |
48 | const loadStatus = async () => {
49 | const stats = await services.getSuggestionStats();
50 | setNewsAnalysisesStats(stats);
51 | };
52 |
53 | useEffect(() => {
54 | loadStatus();
55 | }, []);
56 |
57 | return (
58 |
59 | {newsAnalysisesStats?.map((suggestion: any, index: number) => {
60 | return (
61 |
65 |
66 |
67 |
73 |
74 | +{suggestion.tag_count}
75 |
76 | {suggestion.tag}
77 |
78 |
79 |
80 | {suggestion.stock_names.join('、')}
81 |
82 |
83 |
84 |
94 |
95 |
96 | );
97 | })}
98 |
99 | );
100 | }
101 |
--------------------------------------------------------------------------------
/src/app/trade/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import {
4 | Card,
5 | Select,
6 | Option,
7 | Button,
8 | Chip,
9 | CardContent,
10 | Tooltip,
11 | } from '@mui/joy';
12 |
13 | import { useRouter } from 'next/navigation';
14 | import useData from './useData';
15 | import TagsDialog from './TagsDialog';
16 | import { useState } from 'react';
17 | import cls from 'classnames';
18 | import { toMoney, toPercent, toTradePercent } from '@/utils';
19 |
20 | import NewsAnalysises from './analysis/NewsAnalysises';
21 | import BuyDialog from './stock-list/BuyDialog';
22 | import SellDialog from './stock-list/SellDialog';
23 | import StockList from './stock-list/StockList';
24 | import StockDetail from './stock-detail/StockDetail';
25 | import Dialog from '@/components/Dialog';
26 | import useDialog from '@/components/Dialog/useDialog';
27 |
28 | export default function Workspace() {
29 | const {
30 | pools,
31 | tags,
32 | stocks,
33 | setting,
34 | loading,
35 | changePool,
36 | changeActiveTag,
37 | changeTags,
38 | saveSetting,
39 | sortState,
40 | changeSort,
41 | selectStock,
42 | checkStock,
43 | checkAllStock,
44 | dailyStats,
45 | updateStockEvents,
46 | } = useData();
47 | const router = useRouter();
48 | const [open, setOpen] = useState({
49 | setting: false,
50 | buy: false,
51 | });
52 | const dialog = useDialog();
53 |
54 | const stocksProps = {
55 | stocks,
56 | setOpen,
57 | checkAllStock,
58 | selectStock,
59 | checkStock,
60 | loading,
61 | sortState,
62 | changeSort,
63 | } as any;
64 |
65 | return (
66 | <>
67 |
68 | {dailyStats && (
69 |
70 | 涨跌停:
71 | {dailyStats.limit_up_count}/
72 |
73 | {dailyStats.limit_down_count}
74 |
75 | 涨跌比:
76 | {dailyStats.up_count}/
77 | {dailyStats.down_count}
78 |
79 | 平均涨幅:{toTradePercent(dailyStats.change_pct)}
80 |
81 |
82 | 交易量:{toMoney(dailyStats.turnover, 0)}
83 |
84 |
85 | 同比{dailyStats.turnover_change > 0 ? '放量' : '缩量'}:
86 | {toMoney(dailyStats.turnover_change, 0)}
87 |
88 |
89 | )}
90 |
91 |
92 |
93 | {pools.data?.map((pool, index) => (
94 |
changePool(pool.id)}
100 | >
101 | {pool.stock_pool_name}
102 |
103 | ))}
104 |
105 |
106 |
107 |
108 | {tags.data.map((tag: any) => {
109 | const isSelected = tag.id === tags.current?.id;
110 | const stats = tags.statses.find(
111 | (st: any) => st.main_tag === tag.tag
112 | );
113 | return (
114 |
118 | 涨停数:{stats?.limit_up_count}
119 | 跌停数:{stats?.limit_down_count}
120 | 上涨数:{stats?.up_count}
121 | 下跌数:{stats?.down_count}
122 | 涨幅:{toPercent(stats?.change_pct)}
123 | 成交额:{toMoney(stats?.turnover)}
124 |
125 | }
126 | variant="solid"
127 | >
128 |
{
131 | changeActiveTag(
132 | tag.id === tags.current?.id ? undefined : tag,
133 | pools.current
134 | );
135 | }}
136 | variant={isSelected ? 'solid' : 'soft'}
137 | className="cursor-pointer mr-2 my-0 !px-4"
138 | size="sm"
139 | sx={{
140 | borderRadius: 8,
141 | }}
142 | >
143 |
144 |
145 | {tag.tag}
146 |
147 |
148 |
{toMoney(stats?.turnover)}
149 |
150 | 0
155 | ? 'text-red-800'
156 | : 'text-green-800'
157 | }
158 | >
159 | {toTradePercent(stats?.change_pct)}
160 |
161 |
162 |
163 |
164 |
165 |
166 | );
167 | })}
168 |
169 | {!pools.ignoreSetting && (
170 |
171 |
181 | {/* */}
189 |
190 | )}
191 |
192 |
193 |
198 |
199 |
200 |
205 |
206 |
212 |
213 |
214 |
215 | {/* */}
216 | {open.setting && (
217 | {
222 | changeTags(tags);
223 | saveSetting(tags);
224 | setOpen({ setting: false });
225 | }}
226 | onCancel={() => setOpen({ setting: false })}
227 | />
228 | )}
229 | {open.buy && (
230 |
233 | stocks.data.find((s) => s.entity_id === i)
234 | )}
235 | onSubmit={() => setOpen({ buy: false })}
236 | onCancel={() => setOpen({ buy: false })}
237 | />
238 | )}
239 | {open.sell && (
240 |
243 | stocks.data.find((s) => s.entity_id === i)
244 | )}
245 | onSubmit={() => setOpen({ sell: false })}
246 | onCancel={() => setOpen({ sell: false })}
247 | />
248 | )}
249 | {dialog.open && }
250 | >
251 | );
252 | }
253 |
--------------------------------------------------------------------------------
/src/app/trade/stock-detail/Events.tsx:
--------------------------------------------------------------------------------
1 | import { Chip, Tooltip } from '@mui/joy';
2 | import dayjs from 'dayjs';
3 |
4 | type Props = {
5 | title: string;
6 | events: any[];
7 | };
8 | export default function Events({ title, events }: Props) {
9 | events = events || [];
10 |
11 | return (
12 |
13 |
{title}
14 | {events.length == 0 && (
15 |
暂无
16 | )}
17 | {events.map((event: any, index: number) => (
18 |
22 |
28 | {event.event_type}
29 |
30 | {event.event_content || ''}
}
32 | variant="solid"
33 | // className="w-[400px]"
34 | >
35 |
36 |
37 | {dayjs(event.timestamp).format('YYYY-MM-DD')}
38 |
39 | {event.event_content || ''}
40 |
41 |
42 |
43 | ))}
44 |
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/src/app/trade/stock-detail/News.tsx:
--------------------------------------------------------------------------------
1 | import services from '@/services';
2 | import { Button, Chip, Tooltip } from '@mui/joy';
3 | import { useSetState } from 'ahooks';
4 | import dayjs from 'dayjs';
5 | import { AiOutlineClose } from 'react-icons/ai';
6 | import { useState } from 'react';
7 | import SelectTagTypeDialog from './SelectTagTypeDialog';
8 | import useConfirmDialog from '@/components/Dialog/useConfirmDialog';
9 | import Dialog from '@/components/Dialog';
10 |
11 | type Props = {
12 | title: string;
13 | news: any[];
14 | dialog: any;
15 | entityId: string;
16 | refreshNews: () => any;
17 | };
18 |
19 | const tagTypeText = {
20 | main_tag: '更新主标签',
21 | sub_tag: '更新次标签',
22 | new_tag: '待处理标签',
23 | } as any;
24 |
25 | export default function Events({
26 | title,
27 | news,
28 | refreshNews,
29 | dialog,
30 | entityId,
31 | }: Props) {
32 | news = news || [];
33 |
34 | const confirmDialog = useConfirmDialog();
35 | const [loading, setLoading] = useSetState>({});
36 | const [selectTagDialog, setSelectTagDialog] = useSetState<{
37 | open: boolean;
38 | data: any;
39 | }>({
40 | open: false,
41 | data: {},
42 | });
43 |
44 | const batchUpdateTags = async (
45 | analysis: any,
46 | suggestion: any,
47 | suggestionId: string
48 | ) => {
49 | setLoading({
50 | [suggestionId]: true,
51 | });
52 |
53 | try {
54 | await services.batchUpdateStockTags({
55 | entity_ids: suggestion.stocks.map((s: any) => s.entity_id),
56 | tag: suggestion.tag,
57 | tag_type: suggestion.tag_type,
58 | tag_reason: analysis.news_title,
59 | });
60 | dialog.show({
61 | title: '更新标签成功',
62 | });
63 | } finally {
64 | setLoading({
65 | [suggestionId]: false,
66 | });
67 | }
68 | };
69 |
70 | const showSelectTagType = (analysis: any, suggestion: any) => {
71 | setSelectTagDialog({
72 | open: true,
73 | data: {
74 | entity_ids: suggestion.stocks.map((s: any) => s.entity_id),
75 | tag: suggestion.tag,
76 | tag_type: suggestion.tag_type,
77 | tag_reason: analysis.news_title,
78 | },
79 | });
80 | };
81 |
82 | const handleIgnoreNews = (news: any) => {
83 | confirmDialog.show({
84 | title: '提示',
85 | content: '是否确定忽略此新闻?',
86 | async onOk() {
87 | await services.ignoreStockNews({ news_id: news.id });
88 | refreshNews();
89 | },
90 | });
91 | };
92 |
93 | const handleBuildSuggestions = async () => {
94 | setLoading({ build: true });
95 | try {
96 | if (entityId) {
97 | await services.buildTagSuggestions({ entity_id: entityId });
98 | dialog.show({
99 | title: 'AI分析完成',
100 | });
101 | }
102 | } finally {
103 | setLoading({ build: false });
104 | }
105 | };
106 |
107 | return (
108 |
109 |
110 | {title}
111 |
119 |
120 | {news.length == 0 &&
暂无}
121 |
122 | {news.map((item: any, index: number) => (
123 | -
127 |
130 | {item.news_content || item.news_title}
131 |
132 | }
133 | variant="solid"
134 | >
135 |
136 |
137 | {dayjs(item.timestamp).format('YYYY-MM-DD')}
138 |
139 | {item.news_title || ''}
140 |
141 |
142 | handleIgnoreNews(item)}
144 | className="invisible group-hover:visible absolute right-0 top-[2px] cursor-pointer"
145 | />
146 |
147 | {item.news_analysis?.tag_suggestions?.up?.map(
148 | (suggestion: any, idx: number) => {
149 | const suggestionId = `${index}_up_${idx}`;
150 | const stocksText = suggestion.stocks
151 | .map((s: any, idx: number) => s.name)
152 | .join('、');
153 | return (
154 |
158 |
165 | {suggestion.tag}
166 |
167 |
168 | {stocksText}
}
170 | variant="solid"
171 | >
172 |
{stocksText}
173 |
174 |
175 |
176 |
191 |
192 |
193 | );
194 | }
195 | )}
196 | {item.news_analysis?.tag_suggestions?.down?.map(
197 | (suggestion: any, idx: number) => {
198 | const suggestionId = `${index}_down_${idx}`;
199 | const stocksText = suggestion.stocks
200 | .map((s: any, idx: number) => s.name)
201 | .join('、');
202 | return (
203 |
207 |
214 | {suggestion.tag}
215 |
216 |
217 | {stocksText}
}
219 | variant="solid"
220 | >
221 |
{stocksText}
222 |
223 |
224 |
225 |
240 |
241 |
242 | );
243 | }
244 | )}
245 |
246 |
247 | ))}
248 |
249 | {selectTagDialog.open && (
250 | setSelectTagDialog({ open: false })}
253 | data={selectTagDialog.data}
254 | />
255 | )}
256 | {confirmDialog.open && }
257 |
258 | );
259 | }
260 |
--------------------------------------------------------------------------------
/src/app/trade/stock-detail/SelectTagTypeDialog.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import Button from '@mui/joy/Button';
3 | import DialogTitle from '@mui/joy/DialogTitle';
4 | import DialogActions from '@mui/joy/DialogActions';
5 | import Modal from '@mui/joy/Modal';
6 | import ModalDialog from '@mui/joy/ModalDialog';
7 | import {
8 | FormControl,
9 | FormLabel,
10 | Select,
11 | Stack,
12 | Option,
13 | ModalClose,
14 | } from '@mui/joy';
15 | import { useState } from 'react';
16 | import services from '@/services';
17 |
18 | type Props = {
19 | open: boolean;
20 | onClose(): void;
21 | data: any;
22 | };
23 |
24 | export default function SelectTagTypeDialog({ open, onClose, data }: Props) {
25 | const [loading, setLoading] = useState(false);
26 |
27 | return (
28 | close}>
29 |
30 |
31 |
32 |
67 |
68 |
69 | );
70 | }
71 |
--------------------------------------------------------------------------------
/src/app/trade/stock-detail/StockChart.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Card,
3 | Typography,
4 | Select,
5 | Option,
6 | ToggleButtonGroup,
7 | Button,
8 | } from '@mui/joy';
9 | import { useState } from 'react';
10 | import StockChartKline from './StockChartKline';
11 | import StockChartTs from './StockChartTs';
12 |
13 | type Props = {
14 | entityId: string;
15 | };
16 |
17 | export default function StockChart({ entityId }: Props) {
18 | const [chartType, setChartType] = useState('kline');
19 |
20 | return (
21 |
22 |
23 | setChartType(value as string)}
27 | >
28 |
35 |
42 |
43 |
44 | {chartType === 'kline' &&
}
45 | {chartType === 'ts' &&
}
46 |
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/src/app/trade/stock-detail/StockChartKline.tsx:
--------------------------------------------------------------------------------
1 | import { useAsyncEffect } from 'ahooks';
2 | import { useRef, useEffect } from 'react';
3 | import services from '@/services';
4 | import { init, dispose } from 'klinecharts';
5 |
6 | type Props = {
7 | entityId: string;
8 | };
9 |
10 | const chartCustomTooltips = [
11 | { title: '', value: '{time}' },
12 | { title: '开', value: '{open}' },
13 | { title: '高', value: '{high}' },
14 | { title: '低', value: '{low}' },
15 | { title: '收', value: '{close}' },
16 | { title: '量', value: '{volume}' },
17 | ] as any;
18 |
19 | export default function StockChartKline({ entityId }: Props) {
20 | const chartRef = useRef();
21 |
22 | useEffect(() => {
23 | const loadKData = async () => {
24 | const [kdata] = await services.getKData({ entity_ids: [entityId] });
25 | const datas = kdata.datas.map((item: any) => {
26 | return {
27 | close: item[4],
28 | high: item[2],
29 | low: item[3],
30 | open: item[1],
31 | timestamp: item[0] * 1000,
32 | volume: item[5],
33 | };
34 | });
35 | chartRef.current.applyNewData(datas);
36 | };
37 | loadKData();
38 | const intervalId = setInterval(() => {
39 | loadKData();
40 | }, 60 * 1000);
41 | return () => {
42 | clearInterval(intervalId);
43 | };
44 | }, [entityId]);
45 |
46 | useEffect(() => {
47 | chartRef.current = init('k-line-chart');
48 | chartRef.current.setStyles({
49 | candle: {
50 | type: 'candle_solid',
51 | tooltip: {
52 | custom: chartCustomTooltips,
53 | },
54 | },
55 | });
56 | // 均线图
57 | chartRef.current.createIndicator('MA', false, { id: 'candle_pane' });
58 | chartRef.current.createIndicator('VOL');
59 | chartRef.current.setOffsetRightDistance(10);
60 |
61 | return () => {
62 | dispose('k-line-chart');
63 | };
64 | }, []);
65 |
66 | return ;
67 | }
68 |
--------------------------------------------------------------------------------
/src/app/trade/stock-detail/StockChartTs.tsx:
--------------------------------------------------------------------------------
1 | import { useAsyncEffect } from 'ahooks';
2 | import { useRef, useEffect } from 'react';
3 | import services from '@/services';
4 | import * as echarts from 'echarts';
5 | import dayjs from 'dayjs';
6 |
7 | type Props = {
8 | entityId: string;
9 | };
10 |
11 | const chartCustomTooltips = [
12 | { title: '', value: '{time}' },
13 | { title: '价', value: '{close}' },
14 | { title: '均', value: '{low}' },
15 | { title: '量', value: '{volume}' },
16 | { title: '幅', value: '{open}' },
17 | ] as any;
18 |
19 | export default function StockChartTs({ entityId }: Props) {
20 | const chartRef = useRef();
21 | const chartDomRef = useRef(null);
22 | const fixedTooltipRef = useRef(null);
23 |
24 | useEffect(() => {
25 | const loadTsData = async () => {
26 | const [result] = await services.getTData({
27 | entity_ids: [entityId],
28 | data_provider: 'qmt',
29 | days_count: 5,
30 | });
31 | const datas = result.datas.map((item: any) => {
32 | return {
33 | close: item[1],
34 | avg_price: item[2],
35 | timestamp: item[0],
36 | volume: item[4],
37 | turnover: item[5],
38 | change_pct: item[3],
39 | turnover_rate: item[6],
40 | };
41 | });
42 | const lastTradeTime = datas.at(-1).timestamp;
43 | const lastTradeDayStarTime = dayjs(lastTradeTime)
44 | .startOf('d')
45 | .add(9, 'h')
46 | .add(30, 'm')
47 | .valueOf();
48 | const lastTradeDayEndTime = dayjs(lastTradeTime)
49 | .startOf('d')
50 | .add(15, 'h')
51 | .valueOf();
52 |
53 | // 补全至交易结束时间
54 | let lastTradeFixedTime = lastTradeTime;
55 | while (lastTradeFixedTime < lastTradeDayEndTime) {
56 | lastTradeFixedTime += 60 * 1000;
57 | datas.push({
58 | close: null,
59 | avg_price: null,
60 | timestamp: lastTradeFixedTime,
61 | volume: null,
62 | turnover: null,
63 | change_pct: null,
64 | turnover_rate: null,
65 | });
66 | }
67 |
68 | chartRef.current.setOption({
69 | dataset: {
70 | dimensions: ['timestamp', 'avg_price', 'change_pct'],
71 | source: datas,
72 | },
73 | dataZoom: {
74 | startValue: lastTradeDayStarTime,
75 | endValue: lastTradeDayEndTime,
76 | },
77 | });
78 | };
79 | loadTsData();
80 | const intervalId = setInterval(loadTsData, 60 * 1000);
81 | return () => {
82 | clearInterval(intervalId);
83 | };
84 | }, [entityId]);
85 |
86 | useEffect(() => {
87 | const chart = echarts.init(chartDomRef.current);
88 | chartRef.current = chart;
89 | chart.setOption({
90 | title: {
91 | text: '',
92 | },
93 | tooltip: {
94 | show: true,
95 | trigger: 'axis',
96 | axisPointer: {
97 | type: 'cross',
98 | },
99 | formatter(params: any, ticket: any) {
100 | const value = params[0]?.value;
101 | const { close, avg_price, timestamp, volume, change_pct } = value;
102 | const html = `
103 |
104 |
${dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss')}
105 |
价格:${(close || 0).toFixed(2)}
106 |
均价:${(avg_price || 0).toFixed(2)}
107 |
涨跌:${((change_pct || 0) * 100).toFixed(2)}%
108 |
成交:${volume || 0}
109 |
110 | `;
111 | if (fixedTooltipRef.current) {
112 | fixedTooltipRef.current.innerHTML = `
113 |
114 |
${dayjs(timestamp).format(
115 | 'YYYY-MM-DD HH:mm:ss'
116 | )}
117 |
价${(close || 0).toFixed(2)}
118 |
均${(avg_price || 0).toFixed(
119 | 2
120 | )}
121 |
幅${(
122 | (change_pct || 0) * 100
123 | ).toFixed(2)}%
124 |
量${volume || 0}
125 |
126 | `;
127 | }
128 |
129 | return html;
130 | },
131 | },
132 | dataset: {
133 | dimensions: ['timestamp', 'avg_price', 'change_pct'],
134 | source: [],
135 | },
136 | dataZoom: {
137 | type: 'slider',
138 | xAxisIndex: 0,
139 | },
140 | xAxis: {
141 | type: 'time',
142 | maxInterval: 3600 * 24 * 1000,
143 | minInterval: 60 * 1000,
144 | },
145 | yAxis: [
146 | {
147 | type: 'value',
148 | name: '均价',
149 | position: 'left',
150 | },
151 | {
152 | type: 'value',
153 | name: '涨跌',
154 | position: 'right',
155 | axisLabel: {
156 | formatter(value: number) {
157 | return (value * 100).toFixed(2) + '%';
158 | },
159 | },
160 | axisPointer: {
161 | label: {
162 | formatter: function (params: any) {
163 | return (params.value * 100).toFixed(2) + '%';
164 | },
165 | },
166 | },
167 | },
168 | ],
169 |
170 | series: [
171 | {
172 | name: '均价',
173 | type: 'line',
174 | yAxisIndex: 0,
175 | smooth: true,
176 | connectNulls: true,
177 | showSymbol: false,
178 | encode: {
179 | x: 'timestamp',
180 | y: 'avg_price',
181 | },
182 | lineStyle: {
183 | width: 1,
184 | },
185 | },
186 | {
187 | name: '涨跌',
188 | type: 'line',
189 | yAxisIndex: 1,
190 | smooth: true,
191 | connectNulls: true,
192 | showSymbol: false,
193 | encode: {
194 | x: 'timestamp',
195 | y: 'change_pct',
196 | },
197 | lineStyle: {
198 | width: 1,
199 | },
200 | },
201 | ],
202 | });
203 |
204 | const onResize = () => {
205 | chart.resize();
206 | };
207 |
208 | return () => {
209 | chart.dispose();
210 | };
211 | }, []);
212 |
213 | return (
214 |
218 | );
219 | }
220 |
--------------------------------------------------------------------------------
/src/app/trade/stock-detail/StockDetail.tsx:
--------------------------------------------------------------------------------
1 | import Loading from '@/components/Loading';
2 | import Events from './Events';
3 | import News from './News';
4 | import { Button, Typography } from '@mui/joy';
5 | import TagUpdateDialog from './TagUpdateDialog';
6 | import { useState } from 'react';
7 | import StockChart from './StockChart';
8 |
9 | type Props = {
10 | loading: any;
11 | stocks: any;
12 | dialog: any;
13 | refreshNews: () => any;
14 | };
15 |
16 | export default function StockDetail({
17 | loading,
18 | stocks,
19 | dialog,
20 | refreshNews,
21 | }: Props) {
22 | const [open, setOpen] = useState(false);
23 |
24 | return (
25 | <>
26 |
27 |
28 |
29 | {stocks.current?.name}
30 | {stocks.current?.code}
31 |
32 |
33 |
40 |
41 |
42 | {stocks.current && }
43 |
44 | <>
45 |
46 |
47 |
54 | >
55 |
56 | {open && (
57 | setOpen(false)}
60 | stock={stocks.current}
61 | onCancel={() => setOpen(false)}
62 | />
63 | )}
64 | >
65 | );
66 | }
67 |
--------------------------------------------------------------------------------
/src/app/trade/stock-detail/TagUpdateDialog.tsx:
--------------------------------------------------------------------------------
1 | import Loading from '@/components/Loading';
2 | import services from '@/services';
3 | import {
4 | Modal,
5 | ModalDialog,
6 | DialogTitle,
7 | DialogContent,
8 | Stack,
9 | FormControl,
10 | Button,
11 | Textarea,
12 | FormLabel,
13 | Autocomplete,
14 | ModalClose,
15 | DialogActions,
16 | Typography,
17 | } from '@mui/joy';
18 | import { useRequest, useSetState } from 'ahooks';
19 | import { useEffect, useState } from 'react';
20 |
21 | type HiddenTag = {
22 | id: string;
23 | tag: string;
24 | reason: string;
25 | };
26 |
27 | type Props = {
28 | open: boolean;
29 | onSubmit: () => void;
30 | stock: any;
31 | onCancel: () => void;
32 | };
33 |
34 | // 生成唯一ID的函数
35 | const generateId = () => {
36 | return Math.random().toString(36).substring(2) + Date.now().toString(36);
37 | };
38 |
39 | export default function TagUpdateDialog({
40 | open,
41 | stock,
42 | onSubmit,
43 | onCancel,
44 | }: Props) {
45 | const [state, setState] = useSetState({
46 | main_tag: '',
47 | main_tag_reason: '',
48 | sub_tag: '',
49 | sub_tag_reason: '',
50 | hidden_tags: [] as HiddenTag[],
51 | });
52 | const [saveLoading, setSaveLoading] = useState(false);
53 | const { data: stockTagInfo, loading } = useRequest(
54 | services.getStockTagOptions,
55 | {
56 | defaultParams: [{ entity_id: stock.entity_id }],
57 | }
58 | );
59 |
60 | const saveStockTag = async () => {
61 | setSaveLoading(true);
62 | try {
63 | // 过滤掉空的隐藏标签
64 | const { hidden_tags, ...rest } = state;
65 | const filteredHiddenTags = state.hidden_tags.filter(
66 | (item) => item.tag.trim() !== ''
67 | );
68 |
69 | // 转换为后端需要的格式
70 | const hiddenTagsObj = filteredHiddenTags.reduce((acc, curr) => {
71 | acc[curr.tag] = curr.reason;
72 | return acc;
73 | }, {} as Record);
74 |
75 | await services.updateStockTags({
76 | ...rest,
77 | active_hidden_tags: hiddenTagsObj,
78 | entity_id: stock.entity_id,
79 | });
80 | } finally {
81 | setSaveLoading(false);
82 | }
83 | onSubmit();
84 | };
85 |
86 | useEffect(() => {
87 | if (!stockTagInfo) return;
88 |
89 | // 将对象格式转换为数组格式
90 | const hiddenTagsArray = Object.entries(
91 | stockTagInfo.active_hidden_tags || {}
92 | ).map(([tag, reason]) => ({
93 | id: generateId(),
94 | tag,
95 | reason: reason as string,
96 | }));
97 |
98 | setState({
99 | main_tag: stockTagInfo.main_tag,
100 | main_tag_reason: stockTagInfo.main_tag_options.find(
101 | (x: any) => x.tag === stockTagInfo.main_tag
102 | )?.tag_reason,
103 | sub_tag: stockTagInfo.sub_tag,
104 | sub_tag_reason: stockTagInfo.sub_tag_options.find(
105 | (x: any) => x.tag === stockTagInfo.sub_tag
106 | )?.tag_reason,
107 | hidden_tags: hiddenTagsArray,
108 | });
109 | }, [stockTagInfo]);
110 |
111 | const {
112 | main_tag_options = [],
113 | sub_tag_options = [],
114 | hidden_tag_options = [],
115 | } = stockTagInfo || {};
116 |
117 | const mainTagOptions = main_tag_options.map((x: any) => x.tag);
118 | const subTagOptions = sub_tag_options.map((x: any) => x.tag);
119 |
120 | const handleAddHiddenTag = () => {
121 | // 检查是否已经存在空标签
122 | const hasEmptyTag = state.hidden_tags.some(
123 | (item) => item.tag.trim() === ''
124 | );
125 | if (hasEmptyTag) return;
126 |
127 | setState({
128 | hidden_tags: [
129 | ...state.hidden_tags,
130 | {
131 | id: generateId(),
132 | tag: '',
133 | reason: '',
134 | },
135 | ],
136 | });
137 | };
138 |
139 | const handleRemoveHiddenTag = (id: string) => {
140 | setState({
141 | hidden_tags: state.hidden_tags.filter((item) => item.id !== id),
142 | });
143 | };
144 |
145 | const handleHiddenTagChange = (
146 | id: string,
147 | newTag: string,
148 | reason: string
149 | ) => {
150 | // 检查新标签是否已存在
151 | const existingTag = state.hidden_tags.find(
152 | (item) => item.tag === newTag && item.id !== id
153 | );
154 | if (existingTag) return;
155 |
156 | setState({
157 | hidden_tags: state.hidden_tags.map((item) =>
158 | item.id === id ? { ...item, tag: newTag, reason } : item
159 | ),
160 | });
161 | };
162 |
163 | return (
164 |
165 |
166 |
167 | 修改标签
168 |
169 |
358 |
359 |
360 |
363 |
372 |
373 |
374 |
375 | );
376 | }
377 |
--------------------------------------------------------------------------------
/src/app/trade/stock-list/Blink.tsx:
--------------------------------------------------------------------------------
1 | import { motion } from 'framer-motion';
2 | import { ReactNode } from 'react';
3 |
4 | type Props = {
5 | mkey: string | number;
6 | children?: ReactNode;
7 | };
8 |
9 | export default function Blink({ children, mkey }: Props) {
10 | return (
11 |
25 | {children || mkey}
26 |
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/src/app/trade/stock-list/BuyDialog.tsx:
--------------------------------------------------------------------------------
1 | import services from '@/services';
2 | import {
3 | Modal,
4 | ModalDialog,
5 | DialogTitle,
6 | DialogContent,
7 | Stack,
8 | FormControl,
9 | Button,
10 | Checkbox,
11 | Input,
12 | FormLabel,
13 | Divider,
14 | Select,
15 | Option,
16 | ModalClose,
17 | } from '@mui/joy';
18 | import { useState } from 'react';
19 |
20 | type Props = {
21 | open: boolean;
22 | onSubmit: () => void;
23 | stocks: any[];
24 | onCancel: () => void;
25 | };
26 |
27 | export default function BuyDialog({ open, stocks, onSubmit, onCancel }: Props) {
28 | const [loading, setLoading] = useState(false);
29 | const [type, setType] = useState('normal');
30 |
31 | return (
32 |
33 |
34 |
35 | 买入股票
36 |
127 |
128 |
129 | );
130 | }
131 |
--------------------------------------------------------------------------------
/src/app/trade/stock-list/SellDialog.tsx:
--------------------------------------------------------------------------------
1 | import services from '@/services';
2 | import {
3 | Modal,
4 | ModalDialog,
5 | DialogTitle,
6 | DialogContent,
7 | Stack,
8 | FormControl,
9 | Button,
10 | Checkbox,
11 | Input,
12 | FormLabel,
13 | Divider,
14 | Select,
15 | Option,
16 | ModalClose,
17 | } from '@mui/joy';
18 | import { useState } from 'react';
19 |
20 | type Props = {
21 | open: boolean;
22 | onSubmit: () => void;
23 | stocks: any[];
24 | onCancel: () => void;
25 | };
26 |
27 | export default function SellDialog({
28 | open,
29 | stocks,
30 | onSubmit,
31 | onCancel,
32 | }: Props) {
33 | const [loading, setLoading] = useState(false);
34 | const [type, setType] = useState('normal');
35 |
36 | return (
37 |
38 |
39 |
40 | 卖出股票
41 |
131 |
132 |
133 | );
134 | }
135 |
--------------------------------------------------------------------------------
/src/app/trade/stock-list/SortCell.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from '@mui/joy';
2 | import { ReactNode } from 'react';
3 | import { MdArrowDownward } from 'react-icons/md';
4 |
5 | type Props = {
6 | sortState: {
7 | type: 'asc' | 'desc' | undefined;
8 | field: string | undefined;
9 | };
10 | name: string;
11 | changeSort(field: string, type: string): Promise;
12 | children: ReactNode;
13 | };
14 |
15 | export default function SortCell({
16 | children,
17 | name,
18 | sortState,
19 | changeSort,
20 | }: Props) {
21 | const isSorting = sortState.field === name;
22 | return (
23 | {
29 | changeSort(
30 | name,
31 | isSorting ? (sortState.type === 'asc' ? 'desc' : 'asc') : 'desc'
32 | );
33 | }}
34 | fontWeight="lg"
35 | startDecorator={isSorting ? : ''}
36 | sx={
37 | {
38 | '& svg': {
39 | transition: '0.2s',
40 | transform:
41 | isSorting &&
42 | (sortState.type === 'desc' ? 'rotate(0deg)' : 'rotate(180deg)'),
43 | },
44 | '&:hover': { '& svg': { opacity: 1 } },
45 | } as any
46 | }
47 | >
48 | {children}
49 |
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/src/app/trade/stock-list/StockList.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Table, Card, Button, Checkbox, Tooltip } from '@mui/joy';
4 | import { CircularProgress } from '@mui/joy';
5 |
6 | import SortCell from './SortCell';
7 | import { toMoney } from '@/utils';
8 | import Blink from './Blink';
9 |
10 | type Props = any;
11 |
12 | export default function StockList({
13 | stocks,
14 | setOpen,
15 | checkAllStock,
16 | selectStock,
17 | checkStock,
18 | loading,
19 | sortState,
20 | changeSort,
21 | }: Props) {
22 | const renderHeaderCell = (key: string, title: string) => {
23 | return (
24 |
25 | {title}
26 |
27 | );
28 | };
29 |
30 | return (
31 | <>
32 |
33 |
34 | 已选中 {stocks.checked.length} 只股票{' '}
35 |
36 |
37 | {' '}
45 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 | 0 &&
64 | stocks.checked.length < stocks.data.length
65 | }
66 | checked={
67 | stocks.data.length === stocks.checked.length &&
68 | stocks.checked.length > 0
69 | }
70 | onChange={(event) => {
71 | checkAllStock(event.target.checked);
72 | }}
73 | />
74 | |
75 | 股票名称 |
76 |
77 | {renderHeaderCell('price', '最新价')}
78 | |
79 |
80 | {renderHeaderCell('change_pct', '涨跌幅')}
81 | |
82 |
83 | {renderHeaderCell('turnover', '成交金额')}
84 | |
85 |
86 | {renderHeaderCell('turnover_rate', '换手率')}
87 | |
88 |
89 | {renderHeaderCell('float_cap', '流通市值')}
90 | |
91 |
92 | {renderHeaderCell('total_cap', '总市值')}
93 | |
94 | 主标签 |
95 | 次标签 |
96 | 隐藏标签 |
97 |
98 |
99 |
100 | {stocks?.data?.map((stock: any) => (
101 | selectStock(stock)}
104 | className={`cursor-pointer ${
105 | stock.id === (stocks.current as any)?.id && 'bg-[#E3FBE3]'
106 | }`}
107 | >
108 |
109 | e.stopPropagation()}
113 | onChange={(event) => {
114 | checkStock(stock, event.target.checked);
115 | }}
116 | />
117 | |
118 |
119 | {stock.name}|{stock.code}
120 | |
121 | {stock.price.toFixed(2)} |
122 |
123 |
133 | |
134 |
135 |
136 | |
137 |
138 |
139 | |
140 |
141 |
142 | |
143 |
144 |
145 | |
146 | {stock.main_tag} |
147 | {stock.sub_tag} |
148 |
149 |
152 | {(stock.hidden_tags || []).join('、')}
153 |
154 | }
155 | variant="solid"
156 | >
157 |
158 | {(stock.hidden_tags || []).join('、')}
159 |
160 |
161 | |
162 |
163 | ))}
164 |
165 |
166 |
167 | {loading.stocks && (
168 |
171 |
172 |
173 | )}
174 | >
175 | );
176 | }
177 |
--------------------------------------------------------------------------------
/src/app/trade/useData.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | useAsyncEffect,
3 | useRequest,
4 | useSetState,
5 | useUnmountedRef,
6 | } from 'ahooks';
7 | import { useEffect, useRef, useState } from 'react';
8 | import services from '@/services';
9 | import { getDate } from '@/utils';
10 | import { GlobalTag, Pool, Stock } from '@/interfaces';
11 | import { unescape } from 'querystring';
12 |
13 | type PoolState = {
14 | data: Pool[];
15 | current?: Pool;
16 | ignoreSetting: boolean;
17 | };
18 |
19 | type TagState = {
20 | data: GlobalTag[];
21 | statses: any[];
22 | current?: GlobalTag;
23 | };
24 |
25 | export default function useData() {
26 | const [loading, setLoading] = useSetState({
27 | stocks: false,
28 | setting: false,
29 | events: false,
30 | });
31 | const [pools, setPools] = useSetState({
32 | data: [],
33 | current: undefined,
34 | ignoreSetting: true,
35 | });
36 | const [tags, setTags] = useSetState({
37 | data: [],
38 | statses: [],
39 | current: undefined,
40 | });
41 | const [stocks, setStocks] = useSetState<{
42 | data: any[];
43 | checked: string[];
44 | current: any;
45 | events: any;
46 | }>({
47 | data: [],
48 | checked: [],
49 | current: undefined,
50 | events: undefined,
51 | });
52 | const settingRef = useRef({
53 | stock_pool_name: '',
54 | main_tags: [],
55 | global_tags: [],
56 | });
57 | const sortRef = useRef({
58 | field: '',
59 | type: '',
60 | });
61 |
62 | const intervalId = useRef({
63 | id: undefined,
64 | });
65 | const tagsStatusIntervalId = useRef({
66 | id: undefined,
67 | });
68 | const unmountedRef = useUnmountedRef();
69 |
70 | const { data: dailyStats } = useRequest(services.getDailyQuoteStats, {
71 | pollingInterval: 1000 * 60,
72 | });
73 |
74 | const updatePool = async (pool: Pool) => {
75 | const { stock_pool_name, main_tags, global_tags } = settingRef.current;
76 |
77 | const poolTagStats = await services.getStockStats({
78 | stock_pool_name: pool.stock_pool_name,
79 | target_date: getDate(),
80 | query_type: 'simple',
81 | });
82 | const poolTags = poolTagStats
83 | .map((stats: any) =>
84 | global_tags.find((tag: any) => tag.tag === stats.main_tag)
85 | )
86 | .filter((x: any) => !!x);
87 | let displayTags = []; // global_tags.slice(0, 1);
88 |
89 | // 优先使用pool stats中的tags
90 | if (poolTags.length > 0) {
91 | displayTags = poolTags;
92 | } else if (main_tags.length) {
93 | displayTags = main_tags.map((name: string) =>
94 | global_tags.find((tag: any) => tag.tag === name)
95 | );
96 | } else {
97 | displayTags = global_tags.slice(0, 1);
98 | }
99 |
100 | setPools({
101 | current: pool,
102 | ignoreSetting: poolTags.length > 0, // 查询pool stats中有对应的tag,则不需要进行配置
103 | });
104 |
105 | changeTags(displayTags, pool);
106 |
107 | // setTags({
108 | // data: displayTags,
109 | // });
110 | // changeActiveTag(displayTags[0], pool);
111 | };
112 |
113 | const changePool = async (value: string) => {
114 | setLoading({ stocks: true });
115 | try {
116 | const current = pools.data.find((pool) => pool.id === value);
117 | await updatePool(current as Pool);
118 | } finally {
119 | setLoading({ stocks: false });
120 | }
121 | };
122 |
123 | const changeActiveTag = async (tag: GlobalTag | undefined, pool?: Pool) => {
124 | setLoading({ stocks: true });
125 | setTags({
126 | current: tag,
127 | });
128 |
129 | const params: any = {
130 | stock_pool_name: pool?.stock_pool_name,
131 | main_tag: tag?.tag || undefined,
132 | };
133 | if (sortRef.current.field) {
134 | params.order_by_field = sortRef.current.field;
135 | params.order_by_type = sortRef.current.type;
136 | }
137 |
138 | clearInterval(intervalId.current.id);
139 |
140 | try {
141 | const stocks = await services.getPoolStocksByTag(params);
142 |
143 | clearInterval(intervalId.current.id);
144 | intervalId.current.id = setInterval(() => {
145 | // TODO: 接口返回时间不可控,可能导致前序请求的结果覆盖后序请求的结果
146 | if (unmountedRef.current) {
147 | clearInterval(intervalId.current.id);
148 | }
149 | services.getPoolStocksByTag(params).then((data) => {
150 | updateStocks(data.quotes, true);
151 | });
152 | }, 3000);
153 |
154 | updateStocks(stocks.quotes);
155 | } finally {
156 | setLoading({ stocks: false });
157 | }
158 | };
159 |
160 | const selectStock = async (stock: any) => {
161 | setLoading({ events: true });
162 | setStocks({
163 | current: stock,
164 | });
165 | try {
166 | const events = await services.getStockEvents({
167 | entity_id: stock.entity_id,
168 | });
169 | setStocks({
170 | events,
171 | current: stock,
172 | });
173 | } finally {
174 | setLoading({ events: false });
175 | }
176 | };
177 |
178 | const updateStockEvents = async () => {
179 | setLoading({ events: true });
180 | try {
181 | const events = await services.getStockEvents({
182 | entity_id: stocks.current.entity_id,
183 | });
184 | setStocks({
185 | events,
186 | });
187 | } finally {
188 | setLoading({ events: false });
189 | }
190 | };
191 |
192 | const checkStock = (stock: any, isChecked: boolean) => {
193 | if (isChecked) {
194 | setStocks({
195 | checked: [...stocks.checked, stock.entity_id],
196 | });
197 | } else {
198 | setStocks({
199 | checked: stocks.checked.filter((c) => c !== stock.entity_id),
200 | });
201 | }
202 | };
203 |
204 | const checkAllStock = (isChecked: boolean) => {
205 | setStocks({
206 | checked: isChecked ? stocks.data.map((x) => x.entity_id) : [],
207 | });
208 | };
209 |
210 | const updateStocks = (stocks: any, onlyUpdateData = false) => {
211 | const current = stocks[0];
212 | setStocks({
213 | data: stocks,
214 | });
215 |
216 | if (!onlyUpdateData) {
217 | setStocks({
218 | checked: [],
219 | });
220 | }
221 |
222 | if (!onlyUpdateData && current) {
223 | selectStock(current);
224 | }
225 | };
226 |
227 | const deleteTag = async (tag: GlobalTag) => {
228 | const newTags = tags.data.filter((t) => t.id !== tag.id);
229 | setTags({
230 | data: newTags,
231 | });
232 | if (tags.current?.id === tag.id) {
233 | changeActiveTag(newTags[0]);
234 | }
235 | };
236 |
237 | const changeTags = async (newTags: GlobalTag[], pool?: Pool) => {
238 | pool = pool || pools.current;
239 |
240 | clearInterval(tagsStatusIntervalId.current.id);
241 |
242 | const statses = await services.getTagsStats({
243 | stock_pool_name: pool?.stock_pool_name,
244 | main_tags: newTags.map((t) => t.tag),
245 | });
246 |
247 | const sortedTags = statses.map((stats: any) =>
248 | newTags.find((tag) => tag.tag === stats.main_tag)
249 | );
250 |
251 | // tags 根据 status进行排序
252 | setTags({
253 | data: sortedTags,
254 | statses,
255 | });
256 |
257 | clearInterval(tagsStatusIntervalId.current.id);
258 | // 5秒轮询 查询tag stats
259 | tagsStatusIntervalId.current.id = setInterval(() => {
260 | if (unmountedRef.current) {
261 | clearInterval(tagsStatusIntervalId.current.id);
262 | }
263 | services
264 | .getTagsStats({
265 | stock_pool_name: pool?.stock_pool_name,
266 | main_tags: newTags.map((t) => t.tag),
267 | })
268 | .then((statses) => {
269 | setTags({
270 | statses,
271 | });
272 | });
273 | }, 5000);
274 |
275 | // if (!sortedTags.find((t: any) => t.id === tags.current?.id)) {
276 | changeActiveTag(sortedTags[0], pool);
277 | // }
278 | };
279 |
280 | const saveSetting = async (tags: GlobalTag[]) => {
281 | setLoading({ setting: true });
282 | settingRef.current.stock_pool_name = pools.current?.stock_pool_name;
283 | settingRef.current.main_tags = tags.map((x) => x.tag);
284 | await services.savePoolSetting({
285 | stock_pool_name: settingRef.current.stock_pool_name,
286 | main_tags: settingRef.current.main_tags,
287 | });
288 | setLoading({ setting: false });
289 | };
290 |
291 | const changeSort = async (field: string, type: string) => {
292 | sortRef.current.field = field;
293 | sortRef.current.type = type;
294 | await changeActiveTag(tags.current as any, pools.current);
295 | };
296 |
297 | useAsyncEffect(async () => {
298 | setLoading({ stocks: true });
299 | const [pools, setting, globalTags] = await Promise.all([
300 | services.getPools(),
301 | services.getPoolSetting(),
302 | services.getMainTagInfo(),
303 | ]);
304 |
305 | const defaultPool = pools.find(
306 | (p: any) => p.stock_pool_name === setting.stock_pool_name
307 | );
308 |
309 | setPools({
310 | data: pools,
311 | current: defaultPool,
312 | });
313 | settingRef.current.stock_pool_name = setting.stock_pool_name;
314 | settingRef.current.main_tags = setting.main_tags;
315 | settingRef.current.global_tags = globalTags;
316 |
317 | await updatePool(defaultPool);
318 | setLoading({ stocks: false });
319 | }, []);
320 |
321 | return {
322 | pools,
323 | tags,
324 | stocks,
325 | setting: settingRef.current,
326 | loading,
327 | changePool,
328 | changeTags,
329 | changeActiveTag,
330 | saveSetting,
331 | sortState: sortRef.current,
332 | changeSort,
333 | selectStock,
334 | checkStock,
335 | checkAllStock,
336 | dailyStats,
337 | updateStockEvents,
338 | };
339 | }
340 |
--------------------------------------------------------------------------------
/src/app/workspace/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Card, Select, Option, Typography, Chip } from '@mui/joy';
4 | import { useRouter } from 'next/navigation';
5 | import useData from './useData';
6 |
7 | export default function Workspace() {
8 | const { pools, stocks, stockStats, changePool } = useData();
9 | const router = useRouter();
10 |
11 | const handleStockClick = (entityId: string) => {
12 | router.push('/workspace/stock?entityId=' + entityId);
13 | };
14 |
15 | return (
16 | <>
17 |
18 |
31 |
32 |
33 |
34 | 股票池
35 |
36 |
37 | {stocks.map((stock) => (
38 |
handleStockClick(stock.entity_id)}
42 | >
43 |
{stock.name}
44 |
45 |
46 | {stock.main_tag}
47 |
48 |
49 | {stock.sub_tag}
50 |
51 | {Object.keys(stock.active_hidden_tags || {}).map(
52 | (key, index) => (
53 |
54 | {key}
55 |
56 | )
57 | )}
58 |
59 |
60 | ))}
61 |
62 |
63 | {stockStats.map((stats) => (
64 |
65 |
66 |
67 | {stats.position + 1}
68 | {' '}
69 | {stats.main_tag}
70 |
71 |
72 | {stats.stock_details?.map((stock) => (
73 |
handleStockClick(stock.entity_id)}
77 | >
78 |
{stock.name}
79 |
80 |
81 | {stock.main_tag}
82 |
83 |
84 | {stock.sub_tag}
85 |
86 | {stock.hidden_tags?.map((key, index) => (
87 |
88 | {key}
89 |
90 | ))}
91 |
92 |
93 | ))}
94 |
95 |
96 | ))}
97 | >
98 | );
99 | }
100 |
--------------------------------------------------------------------------------
/src/app/workspace/stock/CandlestickChart.tsx:
--------------------------------------------------------------------------------
1 | import { Card, Typography, Select, Option } from '@mui/joy';
2 | import { useAsyncEffect } from 'ahooks';
3 | import { useState, useRef, useEffect } from 'react';
4 | import * as echarts from 'echarts';
5 | import services from '@/services';
6 | import dayjs from 'dayjs';
7 |
8 | type Props = {
9 | entityId: string;
10 | };
11 |
12 | type KData = {
13 | entity_id: string;
14 | code: string;
15 | name: string;
16 | level: string;
17 | datas: number[][];
18 | };
19 |
20 | type FactorResult = {
21 | entity_id: string;
22 | happen_timestamp: string;
23 | trading_level: string;
24 | trading_signal_type: string;
25 | position_pct: number;
26 | order_amount: number;
27 | order_money: number;
28 | };
29 |
30 | const upColor = '#ec0000';
31 | const upBorderColor = '#8A0000';
32 | const downColor = '#00da3c';
33 | const downBorderColor = '#008F28';
34 |
35 | export default function CandlestickChartDom({ entityId }: Props) {
36 | const [factors, setFactors] = useState([]);
37 | const [kdata, setKdata] = useState();
38 | const [factorResults, setFactorResults] = useState([]);
39 | const [selectedFactor, setSelectedFactor] = useState();
40 |
41 | const chartContainer = useRef(null);
42 | const chartRef = useRef();
43 |
44 | const changeFactor = async (value: string) => {
45 | setSelectedFactor(value);
46 | const results = await services.getFactorResult({
47 | factor_name: 'GoldCrossFactor',
48 | entity_ids: [entityId],
49 | });
50 | setFactorResults(results);
51 | };
52 |
53 | useEffect(() => {
54 | chartRef.current = echarts.init(chartContainer.current);
55 | }, []);
56 |
57 | useAsyncEffect(async () => {
58 | const factors = await services.getFactors();
59 | const [kdata] = await services.getKData({ entity_ids: [entityId] });
60 |
61 | changeFactor('GoldCrossFactor');
62 |
63 | setFactors(factors);
64 | setKdata(kdata);
65 | }, []);
66 |
67 | useEffect(() => {
68 | if (!kdata) return;
69 |
70 | const dates = kdata.datas.map((x) =>
71 | dayjs(x[0] * 1000).format('YYYY-MM-DD')
72 | );
73 | const values = kdata.datas.map((x) => x.slice(1));
74 | const marks = factorResults.map((result) => {
75 | const date = dayjs(result.happen_timestamp).format('YYYY-MM-DD');
76 | const dateIndex = dates.indexOf(date);
77 | const dateValue = values[dateIndex];
78 | const maxValue = Math.max(dateValue[0], dateValue[1]);
79 |
80 | const isBuy = result.trading_signal_type === 'open_long';
81 |
82 | return {
83 | name: isBuy ? '买入' : '卖出',
84 | coord: [date, maxValue],
85 | value: maxValue,
86 | action: result.trading_signal_type,
87 | itemStyle: {
88 | color: isBuy ? '#0958d9' : '#fa541c',
89 | },
90 | };
91 | });
92 |
93 | chartRef.current.setOption({
94 | tooltip: {
95 | trigger: 'axis',
96 | axisPointer: {
97 | type: 'cross',
98 | },
99 | },
100 | xAxis: {
101 | type: 'category',
102 | boundaryGap: false,
103 | axisLine: { onZero: false },
104 | splitLine: { show: false },
105 | min: 'dataMin',
106 | max: 'dataMax',
107 | data: dates,
108 | },
109 | yAxis: {
110 | scale: true,
111 | splitArea: {
112 | show: true,
113 | },
114 | },
115 | dataZoom: [
116 | {
117 | type: 'inside',
118 | start: 90,
119 | end: 100,
120 | },
121 | {
122 | show: true,
123 | type: 'slider',
124 | top: '90%',
125 | start: 90,
126 | end: 100,
127 | },
128 | ],
129 | series: [
130 | {
131 | name: '日K',
132 | type: 'candlestick',
133 | data: values,
134 | itemStyle: {
135 | // color: upColor,
136 | // color0: downColor,
137 | // borderColor: upBorderColor,
138 | // borderColor0: downBorderColor,
139 | },
140 | markPoint: {
141 | label: {
142 | formatter: function (param: Record) {
143 | return param.name;
144 | },
145 | color: '#fff',
146 | },
147 | data: marks,
148 | },
149 | },
150 | ],
151 | });
152 | }, [kdata, factorResults]);
153 |
154 | return (
155 |
156 |
157 | K线图
158 |
159 |
160 |
173 |
174 |
175 |
176 | );
177 | }
178 |
--------------------------------------------------------------------------------
/src/app/workspace/stock/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Card, Typography, Chip, Button } from '@mui/joy';
4 | import { MdOutlineArrowBackIos } from 'react-icons/md';
5 | import { Suspense } from 'react';
6 |
7 | import useData from './useData';
8 | import { getDate } from '@/utils';
9 | import { useRouter } from 'next/navigation';
10 | import CandlestickChart from './CandlestickChart';
11 |
12 | function Stock() {
13 | const router = useRouter();
14 | const { entityId, stockTags, stockHistoryTags } = useData();
15 | const {
16 | main_tags = {},
17 | sub_tags = {},
18 | hidden_tags = {},
19 | active_hidden_tags = {},
20 | } = stockHistoryTags || {};
21 |
22 | return (
23 |
24 |
25 |
26 |
router.back()}
29 | >
30 |
31 |
32 |
33 | {stockTags.name}
34 |
35 |
36 |
37 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 | {stockTags.main_tag}
57 |
58 |
59 |
{stockTags.main_tag_reason}
60 |
61 |
62 |
63 |
64 | {stockTags.sub_tag}
65 |
66 |
67 |
{stockTags.sub_tag_reason}
68 |
69 | {Object.keys(active_hidden_tags || {}).map((key, index) => (
70 |
71 |
72 |
73 | {key}
74 |
75 |
76 |
{active_hidden_tags[key]}
77 |
78 | ))}
79 |
80 |
81 |
82 |
83 | 标签历史
84 |
85 |
86 |
87 |
主标签
88 | {Object.keys(main_tags || {}).map((key) => (
89 |
90 |
91 |
92 | {key}
93 |
94 |
95 |
96 | {main_tags[key]}
97 |
98 |
99 | ))}
100 |
101 |
102 |
次标签
103 | {Object.keys(sub_tags || {}).map((key) => (
104 |
105 |
106 |
107 | {key}
108 |
109 |
110 |
111 | {sub_tags[key]}
112 |
113 |
114 | ))}
115 |
116 |
117 |
隐藏标签
118 | {Object.keys(hidden_tags || {}).map((key) => (
119 |
120 |
121 |
122 | {key}
123 |
124 |
125 |
126 | {hidden_tags[key]}
127 |
128 |
129 | ))}
130 |
131 |
132 |
133 |
134 | );
135 | }
136 |
137 | export default function Page() {
138 | return (
139 |
140 |
141 |
142 | );
143 | }
144 |
--------------------------------------------------------------------------------
/src/app/workspace/stock/useData.tsx:
--------------------------------------------------------------------------------
1 | import { useAsyncEffect } from 'ahooks';
2 | import { useState } from 'react';
3 | import { useSearchParams } from 'next/navigation';
4 | import services from '@/services';
5 |
6 | type StockHistoryTag = {
7 | id: string;
8 | entity_id: string;
9 | main_tag: string;
10 | sub_tag: string;
11 | main_tag_reason: string;
12 | sub_tag_reason: string;
13 | main_tags: Record;
14 | sub_tags: Record;
15 | hidden_tags: Record;
16 | active_hidden_tags: Record;
17 | };
18 |
19 | export default function useData() {
20 | const [stockTags, setStockTags] = useState({});
21 | const [stockHistoryTags, setStockHistoryTags] = useState();
22 |
23 | const searchParams = useSearchParams();
24 | const entityId = searchParams.get('entityId');
25 |
26 | useAsyncEffect(async () => {
27 | const [stockTags] = await services.getSimpleStockTags({
28 | entity_ids: [entityId],
29 | });
30 | const [stockHistoryTags] = await services.getHistoryStockTags({
31 | entity_ids: [entityId],
32 | });
33 |
34 | setStockTags(stockTags);
35 | setStockHistoryTags(stockHistoryTags);
36 | }, []);
37 |
38 | return {
39 | stockTags,
40 | stockHistoryTags,
41 | entityId
42 | };
43 | }
44 |
--------------------------------------------------------------------------------
/src/app/workspace/stock_tag/TagInfoDialog.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Modal,
3 | ModalDialog,
4 | DialogTitle,
5 | DialogContent,
6 | Stack,
7 | FormControl,
8 | Input,
9 | Textarea,
10 | FormLabel,
11 | Button,
12 | } from '@mui/joy';
13 |
14 | type Props = {
15 | open: boolean;
16 | onSubmit: (data: any) => void;
17 | onCancel: () => void;
18 | };
19 |
20 | export default function TagInfoDialog({ open, onSubmit, onCancel }: Props) {
21 | return (
22 |
23 |
24 | 创建新的标签
25 |
26 |
48 |
49 |
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/src/app/workspace/stock_tag/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import {
4 | Card,
5 | Typography,
6 | Chip,
7 | Button,
8 | Input,
9 | Textarea,
10 | Select,
11 | Option,
12 | Stack,
13 | } from '@mui/joy';
14 | import { MdOutlineArrowBackIos } from 'react-icons/md';
15 | import useData from './useData';
16 | import { useRef, useState, Suspense } from 'react';
17 | import { useRequest, useSetState } from 'ahooks';
18 | import { useRouter } from 'next/navigation';
19 | import { fromJSON } from 'postcss';
20 | import services from '@/services';
21 | import TagModal from './TagInfoDialog';
22 | import TagInfoDialog from './TagInfoDialog';
23 |
24 | function StockTag() {
25 | const router = useRouter();
26 |
27 | const {
28 | entityId,
29 | stockTags,
30 | globalMainTags,
31 | globalHiddenTags,
32 | globalSubTags,
33 | stockMainTags,
34 | stockSubTags,
35 | stockHiddenTags,
36 | currentMainTag,
37 | currentSubTag,
38 | currentHiddenTags,
39 | setCurrentSubTag,
40 | setCurrentMainTag,
41 | setCurrentHiddenTags,
42 | refreshTags,
43 | } = useData();
44 | const [tagSourceMap, setTagSourceMap] = useSetState>({
45 | main: 'stock',
46 | sub: 'stock',
47 | });
48 |
49 | const [visible, setVisible] = useState(false);
50 | const tagTypeRef = useRef('');
51 |
52 | const { loading, run: updateStockTags } = useRequest(
53 | services.updateStockTags,
54 | {
55 | manual: true,
56 | }
57 | );
58 |
59 | const mainTagOptions =
60 | tagSourceMap.main === 'stock' ? stockMainTags : globalMainTags;
61 | const subTagOptions =
62 | tagSourceMap.sub === 'stock' ? stockSubTags : globalSubTags;
63 |
64 | const getHiddenTagOptions = (key: string) => {
65 | const source = tagSourceMap[key] ?? 'stock';
66 | return source === 'stock' ? stockHiddenTags : globalHiddenTags;
67 | };
68 |
69 | const changeTagSource = (key: string, value: string | null) => {
70 | setTagSourceMap({ [key]: value as string });
71 | if (key === 'main') {
72 | setCurrentMainTag({
73 | reason: '',
74 | tag: '',
75 | });
76 | } else if (key === 'sub') {
77 | setCurrentSubTag({
78 | reason: '',
79 | tag: '',
80 | });
81 | }
82 | };
83 |
84 | const changeHiddenTagSource = (key: string, value: string | null) => {
85 | setTagSourceMap({ [key]: value as string });
86 | setCurrentHiddenTags((prev) => {
87 | const selectedIndex = prev.findIndex((x) => x.id === key);
88 | if (selectedIndex !== -1) {
89 | prev[selectedIndex] = {
90 | id: key,
91 | reason: '',
92 | tag: '',
93 | };
94 | }
95 | return [...prev];
96 | });
97 | };
98 |
99 | const changeMainTagName = (tagName: string | null) => {
100 | const selectedOption = mainTagOptions.find((x) => x.tag === tagName);
101 | if (selectedOption) {
102 | setCurrentMainTag({
103 | tag: selectedOption.tag,
104 | reason: selectedOption.tag_reason,
105 | });
106 | }
107 | };
108 |
109 | const changeSubTagName = (tagName: string | null) => {
110 | const selectedOption = subTagOptions.find((x) => x.tag === tagName);
111 | if (selectedOption) {
112 | setCurrentSubTag({
113 | tag: selectedOption.tag,
114 | reason: selectedOption.tag_reason,
115 | });
116 | }
117 | };
118 |
119 | const changeHiddenTagName = (key: string, tagName: string | null) => {
120 | const selectedOption = getHiddenTagOptions(key).find(
121 | (x) => x.tag === tagName
122 | );
123 | if (selectedOption) {
124 | setCurrentHiddenTags((prev) => {
125 | const selectedTag = prev.find((x) => x.id === key);
126 | const selectedIndex = prev.findIndex((x) => x.id === key);
127 | if (selectedTag) {
128 | prev[selectedIndex] = {
129 | ...selectedTag,
130 | tag: tagName as string,
131 | reason: selectedOption.tag_reason,
132 | };
133 | }
134 | return [...prev];
135 | });
136 | }
137 | };
138 |
139 | const changeHiddenTagReason = (key: string, value: string) => {
140 | setCurrentHiddenTags((prev) => {
141 | const selectedTag = prev.find((x) => x.id === key);
142 | const selectedIndex = prev.findIndex((x) => x.id === key);
143 | if (selectedTag) {
144 | prev[selectedIndex] = {
145 | ...selectedTag,
146 | reason: value,
147 | };
148 | }
149 | return [...prev];
150 | });
151 | };
152 |
153 | const addHiddenTag = () => {
154 | const key = Date.now() + '';
155 | setCurrentHiddenTags((x) => [
156 | ...(x || []),
157 | { id: key, tag: '', reason: '' },
158 | ]);
159 | setTagSourceMap({ [key]: 'stock' });
160 | };
161 |
162 | const removeHiddenTag = (key: string) => {
163 | setCurrentHiddenTags((x) => x?.filter((item) => item.id !== key));
164 | };
165 |
166 | const saveStockTag = async (data: any) => {
167 | const {
168 | main_tag,
169 | main_tag_reason,
170 | sub_tag,
171 | sub_tag_reason,
172 | ...hiddenTagData
173 | } = data;
174 |
175 | const active_hidden_tags: Record = {};
176 | Object.keys(hiddenTagData).forEach((key) => {
177 | if (
178 | key.startsWith('hidden_tag_') &&
179 | !key.startsWith('hidden_tag_reason')
180 | ) {
181 | const id = key.replace('hidden_tag_', '');
182 | const tagName = hiddenTagData[key];
183 | const tagReason = hiddenTagData[`hidden_tag_reason_${id}`];
184 | active_hidden_tags[tagName] = tagReason;
185 | }
186 | });
187 |
188 | await updateStockTags({
189 | entity_id: entityId,
190 | main_tag,
191 | main_tag_reason,
192 | sub_tag,
193 | sub_tag_reason,
194 | active_hidden_tags,
195 | });
196 |
197 | router.back();
198 | };
199 |
200 | const showTagInfo = (type: string) => () => {
201 | tagTypeRef.current = type;
202 | setVisible(true);
203 | };
204 |
205 | const saveTagInfo = async (data: any) => {
206 | const tagType = tagTypeRef.current;
207 | if (tagType === 'main') {
208 | await services.createMainTagInfo(data);
209 | } else if (tagType === 'sub') {
210 | await services.createSubTagInfo(data);
211 | } else {
212 | await services.createHiddenTagInfo(data);
213 | }
214 | await refreshTags();
215 | setVisible(false);
216 | };
217 |
218 | const cancelTagInfo = () => {
219 | setVisible(false);
220 | };
221 |
222 | return (
223 |
224 |
225 |
226 |
router.back()}
229 | >
230 |
231 |
232 |
233 | 修改标签
234 |
235 |
236 |
237 |
451 |
456 |
457 | );
458 | }
459 |
460 | export default function Page() {
461 | return (
462 |
463 |
464 |
465 | );
466 | }
467 |
--------------------------------------------------------------------------------
/src/app/workspace/stock_tag/useData.ts:
--------------------------------------------------------------------------------
1 | import { useAsyncEffect, useSetState } from 'ahooks';
2 | import { useState } from 'react';
3 | import { useSearchParams } from 'next/navigation';
4 | import services from '@/services';
5 | import { GlobalTag, StockHistoryTag, TagState } from '@/interfaces';
6 |
7 | function tagObject2Array(obj: Record): GlobalTag[] {
8 | return Object.keys(obj).map((key, index) => ({
9 | id: index + '',
10 | tag: key,
11 | tag_reason: obj[key],
12 | }));
13 | }
14 |
15 | export default function useData() {
16 | const [stockTags, setStockTags] = useState({});
17 | const [stockHistoryTags, setStockHistoryTags] = useState();
18 | const [globalMainTags, setGlobalMainTag] = useState([]);
19 | const [globalSubTags, setGlobalSubTag] = useState([]);
20 | const [globalHiddenTags, setGlobalHiddenTag] = useState([]);
21 |
22 | const [currentMainTag, setCurrentMainTag] = useSetState({
23 | id: 'main',
24 | tag: '',
25 | reason: '',
26 | });
27 | const [currentSubTag, setCurrentSubTag] = useSetState({
28 | id: 'sub',
29 | tag: '',
30 | reason: '',
31 | });
32 | const [currentHiddenTags, setCurrentHiddenTags] = useState([]);
33 |
34 | const searchParams = useSearchParams();
35 | const entityId = searchParams.get('entityId');
36 |
37 | const fetchTags = async () => {
38 | const [stockTags] = await services.getSimpleStockTags({
39 | entity_ids: [entityId],
40 | });
41 | const [stockHistoryTags] = await services.getHistoryStockTags({
42 | entity_ids: [entityId],
43 | });
44 |
45 | const [gloablMainTags, globalSubTags, globalHiddenTags] = await Promise.all(
46 | [
47 | services.getMainTagInfo(),
48 | services.getSubTagInfo(),
49 | services.getHiddenTagInfo(),
50 | ]
51 | );
52 |
53 | setStockTags(stockTags);
54 | setStockHistoryTags(stockHistoryTags);
55 | setGlobalMainTag(gloablMainTags || []);
56 | setGlobalSubTag(globalSubTags || []);
57 | setGlobalHiddenTag(globalHiddenTags || []);
58 |
59 | setCurrentMainTag({
60 | tag: stockTags.main_tag,
61 | reason: stockTags.main_tag_reason,
62 | });
63 | setCurrentSubTag({
64 | tag: stockTags.sub_tag,
65 | reason: stockTags.sub_tag_reason,
66 | });
67 |
68 | const activeHiddenTags = tagObject2Array(
69 | stockHistoryTags?.active_hidden_tags || {}
70 | );
71 |
72 | setCurrentHiddenTags(
73 | activeHiddenTags.map((x) => ({
74 | id: x.id,
75 | tag: x.tag,
76 | reason: x.tag_reason,
77 | }))
78 | );
79 | };
80 |
81 | useAsyncEffect(fetchTags, []);
82 |
83 | const stockMainTags = tagObject2Array(stockHistoryTags?.main_tags || {});
84 | const stockSubTags = tagObject2Array(stockHistoryTags?.sub_tags || {});
85 | const stockHiddenTags = tagObject2Array(stockHistoryTags?.hidden_tags || {});
86 |
87 | return {
88 | entityId,
89 | stockTags,
90 | stockHistoryTags,
91 | globalMainTags,
92 | globalSubTags,
93 | globalHiddenTags,
94 | stockMainTags,
95 | stockSubTags,
96 | stockHiddenTags,
97 | currentMainTag,
98 | currentSubTag,
99 | currentHiddenTags,
100 | setCurrentMainTag,
101 | setCurrentSubTag,
102 | setCurrentHiddenTags,
103 | refreshTags: fetchTags,
104 | };
105 | }
106 |
--------------------------------------------------------------------------------
/src/app/workspace/useData.ts:
--------------------------------------------------------------------------------
1 | import { useAsyncEffect, useSetState } from 'ahooks';
2 | import { useEffect, useState } from 'react';
3 | import services from '@/services';
4 | import { getDate } from '@/utils';
5 | import { Pool, Stock, StockItemStats } from '@/interfaces';
6 |
7 | export type PoolState = {
8 | data: Pool[];
9 | current?: Pool;
10 | entityIds: string[];
11 | };
12 |
13 | export default function useData() {
14 | const [pools, setPools] = useSetState({
15 | data: [],
16 | current: undefined,
17 | entityIds: [],
18 | });
19 | const [stocks, setStocks] = useState([]);
20 | const [stockStats, setStockStats] = useState([]);
21 |
22 | const updatePool = async (pool: Pool) => {
23 | setPools({
24 | current: pool,
25 | });
26 | const { entity_ids } = await services.getPoolEntities({
27 | stock_pool_name: pool.stock_pool_name,
28 | });
29 | setPools({
30 | entityIds: entity_ids,
31 | });
32 |
33 | const stocks = await services.getSimpleStockTags({
34 | entity_ids: entity_ids,
35 | });
36 | setStocks(stocks);
37 |
38 | const stockStats = await services.getStockStats({
39 | stock_pool_name: pool.stock_pool_name,
40 | target_date: '2024-04-08', //getDate()
41 | });
42 | setStockStats(stockStats);
43 | };
44 |
45 | const changePool = async (value: string) => {
46 | const current = pools.data.find((pool: Pool) => pool.id === value);
47 | updatePool(current as Pool);
48 | };
49 |
50 | useAsyncEffect(async () => {
51 | const data = await services.getPools();
52 | setPools({
53 | data,
54 | });
55 | updatePool(data[0]);
56 | }, []);
57 |
58 | return {
59 | pools,
60 | stocks,
61 | stockStats,
62 | changePool,
63 | };
64 | }
65 |
--------------------------------------------------------------------------------
/src/components/Dialog/Confirm.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import Button from '@mui/joy/Button';
3 | import Divider from '@mui/joy/Divider';
4 | import DialogTitle from '@mui/joy/DialogTitle';
5 | import DialogContent from '@mui/joy/DialogContent';
6 | import DialogActions from '@mui/joy/DialogActions';
7 | import Modal from '@mui/joy/Modal';
8 | import ModalDialog from '@mui/joy/ModalDialog';
9 | import { ModalClose } from '@mui/joy';
10 |
11 | type Props = {
12 | open: boolean;
13 | onClose(isOk?: boolean): void;
14 | title: string;
15 | content: string;
16 | };
17 |
18 | export default function Confirm({ open, onClose, title, content }: Props) {
19 | return (
20 | close}>
21 |
22 |
23 |
24 | {/* */}
25 | {title}
26 |
27 | {/* */}
28 | {content}
29 |
30 |
38 |
46 |
47 |
48 |
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/src/components/Dialog/Info.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import Button from '@mui/joy/Button';
3 | import Divider from '@mui/joy/Divider';
4 | import DialogTitle from '@mui/joy/DialogTitle';
5 | import DialogContent from '@mui/joy/DialogContent';
6 | import DialogActions from '@mui/joy/DialogActions';
7 | import Modal from '@mui/joy/Modal';
8 | import ModalDialog from '@mui/joy/ModalDialog';
9 | import DeleteForever from '@mui/icons-material/DeleteForever';
10 | import WarningRoundedIcon from '@mui/icons-material/WarningRounded';
11 | import { AiFillCheckCircle } from 'react-icons/ai';
12 | import { ModalClose } from '@mui/joy';
13 |
14 | type Props = {
15 | open: boolean;
16 | onClose(): void;
17 | title: string;
18 | content: string;
19 | };
20 |
21 | export default function Info({ open, onClose, title, content }: Props) {
22 | return (
23 | onClose()}>
24 |
30 |
31 |
32 | {title}
33 |
34 | {content && {content}}
35 |
36 |
44 |
45 |
46 |
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/src/components/Dialog/index.ts:
--------------------------------------------------------------------------------
1 | import Confirm from './Confirm';
2 | import Info from './Info';
3 |
4 | export default {
5 | Confirm,
6 | Info,
7 | };
8 |
--------------------------------------------------------------------------------
/src/components/Dialog/useConfirmDialog.tsx:
--------------------------------------------------------------------------------
1 | import { useSetState } from 'ahooks';
2 |
3 | export default function useDialog() {
4 | const [dialog, setDialog] = useSetState({
5 | title: '',
6 | content: '',
7 | open: false,
8 | onOk() {},
9 | onCancel() {},
10 | });
11 |
12 | const show = ({
13 | title,
14 | content,
15 | onOk = () => {},
16 | onCancel = () => {},
17 | }: {
18 | title: string;
19 | content?: string;
20 | onOk(): void;
21 | onCancel?: () => void;
22 | }) => {
23 | setDialog({
24 | title,
25 | content: content || '',
26 | open: true,
27 | onOk,
28 | onCancel,
29 | });
30 | };
31 |
32 | const close = (isOk: boolean) => {
33 | setDialog({
34 | open: false,
35 | });
36 | if (isOk) {
37 | dialog.onOk();
38 | } else {
39 | dialog.onCancel();
40 | }
41 | };
42 |
43 | return {
44 | props: {
45 | ...dialog,
46 | onClose: close,
47 | },
48 | open: dialog.open,
49 | show,
50 | close,
51 | };
52 | }
53 |
--------------------------------------------------------------------------------
/src/components/Dialog/useDialog.tsx:
--------------------------------------------------------------------------------
1 | import { useSetState } from 'ahooks';
2 |
3 | export default function useDialog() {
4 | const [dialog, setDialog] = useSetState({
5 | title: '',
6 | content: '',
7 | open: false,
8 | });
9 |
10 |
11 | const show = ({ title, content }: { title: string; content?: string }) => {
12 | setDialog({
13 | title,
14 | content: content || '',
15 | open: true,
16 | });
17 | };
18 |
19 | const close = () => {
20 | setDialog({
21 | open: false,
22 | });
23 | };
24 |
25 | return {
26 | props: {
27 | ...dialog,
28 | onClose: close,
29 | },
30 | open: dialog.open,
31 | show,
32 | close,
33 | };
34 | }
35 |
--------------------------------------------------------------------------------
/src/components/Loading/index.tsx:
--------------------------------------------------------------------------------
1 | import { CircularProgress } from '@mui/joy';
2 | import { ReactNode } from 'react';
3 |
4 | type Props = {
5 | loading: boolean;
6 | children: ReactNode;
7 | className?: string;
8 | fixedTop?: number;
9 | };
10 |
11 | export default function Loading({
12 | loading,
13 | children,
14 | className,
15 | fixedTop = 0,
16 | }: Props) {
17 | if (!loading) return children;
18 |
19 | const centerCls = 'flex justify-center items-center';
20 | const fixedTopCls = `flex justify-center items-start pt-[${fixedTop}px]`;
21 |
22 | return (
23 |
28 |
29 |
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/src/components/ThemeRegistry/EmotionCache.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import * as React from 'react';
3 | import createCache from '@emotion/cache';
4 | import { useServerInsertedHTML } from 'next/navigation';
5 | import { CacheProvider as DefaultCacheProvider } from '@emotion/react';
6 | import type {
7 | EmotionCache,
8 | Options as OptionsOfCreateCache,
9 | } from '@emotion/cache';
10 |
11 | export type NextAppDirEmotionCacheProviderProps = {
12 | /** This is the options passed to createCache() from 'import createCache from "@emotion/cache"' */
13 | options: Omit;
14 | /** By default from 'import { CacheProvider } from "@emotion/react"' */
15 | CacheProvider?: React.ElementType<{ value: EmotionCache }>;
16 | children: React.ReactNode;
17 | };
18 |
19 | // Adapted from https://github.com/garronej/tss-react/blob/main/src/next/appDir.tsx
20 | export default function NextAppDirEmotionCacheProvider(
21 | props: NextAppDirEmotionCacheProviderProps
22 | ) {
23 | const { options, CacheProvider = DefaultCacheProvider, children } = props;
24 |
25 | const [registry] = React.useState(() => {
26 | const cache = createCache(options);
27 | cache.compat = true;
28 | const prevInsert = cache.insert;
29 | let inserted: { name: string; isGlobal: boolean }[] = [];
30 | cache.insert = (...args) => {
31 | const [selector, serialized] = args;
32 | if (cache.inserted[serialized.name] === undefined) {
33 | inserted.push({
34 | name: serialized.name,
35 | isGlobal: !selector,
36 | });
37 | }
38 | return prevInsert(...args);
39 | };
40 | const flush = () => {
41 | const prevInserted = inserted;
42 | inserted = [];
43 | return prevInserted;
44 | };
45 | return { cache, flush };
46 | });
47 |
48 | useServerInsertedHTML(() => {
49 | const inserted = registry.flush();
50 | if (inserted.length === 0) {
51 | return null;
52 | }
53 | let styles = '';
54 | let dataEmotionAttribute = registry.cache.key;
55 |
56 | const globals: {
57 | name: string;
58 | style: string;
59 | }[] = [];
60 |
61 | inserted.forEach(({ name, isGlobal }) => {
62 | const style = registry.cache.inserted[name];
63 |
64 | if (typeof style !== 'boolean') {
65 | if (isGlobal) {
66 | globals.push({ name, style });
67 | } else {
68 | styles += style;
69 | dataEmotionAttribute += ` ${name}`;
70 | }
71 | }
72 | });
73 |
74 | return (
75 |
76 | {globals.map(({ name, style }) => (
77 |
83 | ))}
84 | {styles && (
85 |
90 | )}
91 |
92 | );
93 | });
94 |
95 | return {children};
96 | }
97 |
--------------------------------------------------------------------------------
/src/components/ThemeRegistry/ThemeRegistry.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import * as React from 'react';
3 | import { CssVarsProvider } from '@mui/joy/styles';
4 | import CssBaseline from '@mui/joy/CssBaseline';
5 | import NextAppDirEmotionCacheProvider from './EmotionCache';
6 | import theme from './theme';
7 |
8 | export default function ThemeRegistry({
9 | children,
10 | }: {
11 | children: React.ReactNode;
12 | }) {
13 | return (
14 |
15 |
16 |
17 | {children}
18 |
19 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/src/components/ThemeRegistry/theme.ts:
--------------------------------------------------------------------------------
1 | import { extendTheme } from '@mui/joy/styles';
2 | import { Inter, Source_Code_Pro } from 'next/font/google';
3 |
4 | const inter = Inter({
5 | subsets: ['latin'],
6 | adjustFontFallback: false, // prevent NextJS from adding its own fallback font
7 | fallback: ['var(--joy-fontFamily-fallback)'], // use Joy UI's fallback font
8 | display: 'swap',
9 | });
10 |
11 | const sourceCodePro = Source_Code_Pro({
12 | subsets: ['latin'],
13 | adjustFontFallback: false, // prevent NextJS from adding its own fallback font
14 | fallback: [
15 | // the default theme's fallback for monospace fonts
16 | 'ui-monospace',
17 | 'SFMono-Regular',
18 | 'Menlo',
19 | 'Monaco',
20 | 'Consolas',
21 | 'Liberation Mono',
22 | 'Courier New',
23 | 'monospace',
24 | ],
25 | display: 'swap',
26 | });
27 |
28 | const palette = {
29 | primary: {
30 | solidBg: '#0d6efd',
31 | solidHoverBg: '#0b5ed7',
32 | solidActiveBg: '#0a58ca',
33 | },
34 | };
35 |
36 | const theme = extendTheme({
37 | fontFamily: {
38 | body: inter.style.fontFamily,
39 | display: inter.style.fontFamily,
40 | code: sourceCodePro.style.fontFamily,
41 | },
42 | colorSchemes: {
43 | light: { palette },
44 | },
45 | components: {
46 | JoyButton: {
47 | // styleOverrides: {
48 | // root: ({ ownerState }) => ({
49 | // ...(ownerState.color === 'primary' && {
50 | // backgroundColor: '#4338ca',
51 | // }),
52 | // }),
53 | // },
54 | },
55 | },
56 | });
57 |
58 | export default theme;
59 |
--------------------------------------------------------------------------------
/src/components/layout/ConditionalLayout.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { usePathname } from 'next/navigation';
4 | import { ReactNode, useEffect } from 'react';
5 | import { Box, Typography, CircularProgress } from '@mui/joy';
6 | import Header from './Header';
7 | import { useAuth } from '@/hooks/useAuth';
8 |
9 | interface ConditionalLayoutProps {
10 | children: ReactNode;
11 | }
12 |
13 | export default function ConditionalLayout({
14 | children,
15 | }: ConditionalLayoutProps) {
16 | const pathname = usePathname();
17 | const { isAuthenticated, isLoading } = useAuth();
18 |
19 | // 不需要显示Header的页面路径
20 | const noHeaderPages = ['/login'];
21 |
22 | // 不需要认证的页面路径
23 | const publicRoutes = ['/login'];
24 |
25 | const shouldShowHeader = !noHeaderPages.includes(pathname);
26 | const needsAuth = !publicRoutes.includes(pathname);
27 |
28 | // 正在检查认证状态时显示加载
29 | if (isLoading) {
30 | return (
31 |
41 |
42 | 验证登录状态...
43 |
44 | );
45 | }
46 |
47 | // 如果需要认证但用户未登录,useAuth hook会自动处理跳转
48 | // 这里显示跳转提示
49 | if (needsAuth && !isAuthenticated) {
50 | return (
51 |
61 | 正在跳转到登录页面...
62 |
63 | );
64 | }
65 |
66 | if (!shouldShowHeader) {
67 | // 对于登录等页面,返回全屏布局
68 | return <>{children}>;
69 | }
70 |
71 | // 对于其他页面,使用带Header的布局
72 | return (
73 | <>
74 |
75 | {children}
76 | >
77 | );
78 | }
79 |
--------------------------------------------------------------------------------
/src/components/layout/Header.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import services from '@/services';
4 | import { toMoney, toTradePercent } from '@/utils';
5 | import { useRequest } from 'ahooks';
6 | import Link from 'next/link';
7 | import { usePathname } from 'next/navigation';
8 |
9 | export default function Header() {
10 | const pathname = usePathname();
11 | const { data } = useRequest(services.getTimeMessage);
12 |
13 | const activeCls = (name: string) =>
14 | pathname.startsWith(name) ? '!border-[#0d6efd]' : '';
15 | return (
16 |
17 |
18 |
ZVT
19 |
20 |
{data?.message}
21 |
26 | 交易
27 |
28 |
33 | 工作区
34 |
35 |
40 | 数据预览
41 |
42 |
47 | 数据因子
48 |
49 |
50 |
51 |
52 | );
53 | }
54 |
--------------------------------------------------------------------------------
/src/hooks/useAuth.ts:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useEffect, useState } from 'react';
4 | import { useRouter, usePathname } from 'next/navigation';
5 | import { TokenManager } from '@/services/http';
6 |
7 | export const useAuth = () => {
8 | const [isAuthenticated, setIsAuthenticated] = useState(null);
9 | const [isLoading, setIsLoading] = useState(true);
10 | const router = useRouter();
11 | const pathname = usePathname();
12 |
13 | // 不需要认证的页面
14 | const publicRoutes = ['/login'];
15 |
16 | useEffect(() => {
17 | const checkAuth = () => {
18 | const authenticated = TokenManager.hasValidToken();
19 | setIsAuthenticated(authenticated);
20 | setIsLoading(false);
21 |
22 | // 如果当前页面需要认证但用户未登录,跳转到登录页
23 | if (!authenticated && !publicRoutes.includes(pathname)) {
24 | router.push('/login');
25 | return;
26 | }
27 |
28 | // 如果用户已登录且在登录页面,跳转到交易页面
29 | if (authenticated && pathname === '/login') {
30 | router.push('/trade');
31 | return;
32 | }
33 | };
34 |
35 | checkAuth();
36 | }, [pathname, router]);
37 |
38 | return {
39 | isAuthenticated,
40 | isLoading,
41 | };
42 | };
43 |
44 | export const useAuthRequired = () => {
45 | const { isAuthenticated, isLoading } = useAuth();
46 |
47 | return {
48 | isAuthenticated: isAuthenticated === true,
49 | isLoading,
50 | };
51 | };
52 |
--------------------------------------------------------------------------------
/src/interfaces/index.ts:
--------------------------------------------------------------------------------
1 | export type StockHistoryTag = {
2 | id: string;
3 | entity_id: string;
4 | main_tag: string;
5 | sub_tag: string;
6 | main_tag_reason: string;
7 | sub_tag_reason: string;
8 | main_tags: Record;
9 | sub_tags: Record;
10 | hidden_tags: Record;
11 | active_hidden_tags: Record;
12 | };
13 |
14 | export type GlobalTag = {
15 | id: string;
16 | tag: string;
17 | tag_reason: string;
18 | };
19 |
20 | export type TagState = {
21 | id: string;
22 | tag: string;
23 | reason: string;
24 | };
25 |
26 | export type Pool = {
27 | id: string;
28 | entity_id: string;
29 | stock_pool_type: string;
30 | stock_pool_name: string;
31 | };
32 |
33 | export type Stock = {
34 | entity_id: string;
35 | name: string;
36 | main_tag: string;
37 | main_tag_reason: string;
38 | sub_tag: string;
39 | sub_tag_reason: string;
40 | active_hidden_tags: {
41 | additionalProp1: string;
42 | additionalProp2: string;
43 | additionalProp3: string;
44 | }[];
45 | };
46 |
47 | export type StockItemStats = {
48 | id: string;
49 | entity_id: string;
50 | timestamp: number;
51 | main_tag: string;
52 | turnover: number;
53 | entity_count: number;
54 | position: number;
55 | is_main_line: true;
56 | main_line_continuous_days: number;
57 | entity_ids: string[];
58 | stock_details: {
59 | entity_id: string;
60 | name: string;
61 | main_tag: string;
62 | sub_tag: string;
63 | hidden_tags: string[];
64 | recent_reduction: true;
65 | recent_unlock: true;
66 | recent_additional_or_rights_issue: true;
67 | }[];
68 | };
69 |
--------------------------------------------------------------------------------
/src/services/http.ts:
--------------------------------------------------------------------------------
1 | import qs from 'qs';
2 |
3 | type InstanceOptions = {
4 | domain?: string;
5 | apis: Record;
6 | withAuth?: boolean; // 是否需要认证
7 | };
8 |
9 | type RequestOptions = {
10 | method: string;
11 | headers?: Record;
12 | withAuth?: boolean;
13 | };
14 |
15 | interface IServiceRequestFn {
16 | (data?: any, config?: any): Promise;
17 | }
18 |
19 | // Token 管理
20 | export const TokenManager = {
21 | // 存储 token
22 | setToken: (token: string) => {
23 | if (typeof window !== 'undefined') {
24 | localStorage.setItem('access_token', token);
25 | }
26 | },
27 |
28 | // 获取 token
29 | getToken: (): string | null => {
30 | if (typeof window !== 'undefined') {
31 | return localStorage.getItem('access_token');
32 | }
33 | return null;
34 | },
35 |
36 | // 移除 token
37 | removeToken: () => {
38 | if (typeof window !== 'undefined') {
39 | localStorage.removeItem('access_token');
40 | }
41 | },
42 |
43 | // 检查是否有有效 token
44 | hasValidToken: (): boolean => {
45 | const token = TokenManager.getToken();
46 | if (!token) return false;
47 |
48 | try {
49 | // 解析 JWT token 的 payload
50 | const payload = JSON.parse(atob(token.split('.')[1]));
51 | const currentTime = Math.floor(Date.now() / 1000);
52 |
53 | // 检查是否过期
54 | return payload.exp > currentTime;
55 | } catch (error) {
56 | console.error('Token validation error:', error);
57 | return false;
58 | }
59 | },
60 | };
61 |
62 | // 认证失效处理
63 | const handleAuthError = () => {
64 | TokenManager.removeToken();
65 | if (typeof window !== 'undefined') {
66 | window.location.href = '/login';
67 | }
68 | };
69 |
70 | function parseServiceUrl(value: string) {
71 | let method;
72 | let url;
73 | if (value.startsWith('GET')) {
74 | method = 'GET';
75 | url = value.replace('GET', '').trim();
76 | } else {
77 | method = 'POST';
78 | url = value.trim();
79 | }
80 | return { method, url };
81 | }
82 |
83 | export function createInstance({
84 | apis,
85 | withAuth = true,
86 | }: InstanceOptions) {
87 | const internalRequest = async (
88 | url: string,
89 | data: any,
90 | config?: RequestOptions
91 | ) => {
92 | let domain = process.env.NEXT_PUBLIC_SERVER as string;
93 | if (typeof window !== 'undefined') {
94 | domain = (window as any)?.SERVER_HOST || domain;
95 | }
96 | let realUrl = domain + url;
97 |
98 | const options: any = {
99 | method: config?.method || 'POST',
100 | mode: 'cors',
101 | headers: {
102 | 'Content-Type': 'application/json',
103 | ...config?.headers,
104 | },
105 | };
106 |
107 | // 添加认证 token
108 | const needsAuth = config?.withAuth !== false && withAuth;
109 | if (needsAuth) {
110 | const token = TokenManager.getToken();
111 | if (token) {
112 | options.headers.Authorization = `Bearer ${token}`;
113 | }
114 | }
115 |
116 | if (data) {
117 | if (options.method === 'GET') {
118 | realUrl = realUrl + '?' + qs.stringify(data);
119 | } else {
120 | options.body = JSON.stringify(data);
121 | }
122 | }
123 |
124 | try {
125 | const response = await fetch(realUrl, options);
126 |
127 | // 处理 401 认证失效
128 | if (response.status === 401 && needsAuth) {
129 | handleAuthError();
130 | throw new Error('认证失效,请重新登录');
131 | }
132 |
133 | if (!response.ok) {
134 | throw new Error(`HTTP error! status: ${response.status}`);
135 | }
136 |
137 | return response.json();
138 | } catch (error) {
139 | console.error('Request failed:', error);
140 | throw error;
141 | }
142 | };
143 |
144 | const serviceInstance = {} as Record;
145 |
146 | Object.keys(apis).forEach((apiName) => {
147 | serviceInstance[apiName as T] = function serviceWrapFn(
148 | data: any,
149 | config?: any
150 | ) {
151 | const { url, method } = parseServiceUrl(apis[apiName as T]);
152 | return internalRequest(url, data, {
153 | method,
154 | ...config,
155 | });
156 | };
157 | });
158 |
159 | return serviceInstance;
160 | }
161 |
--------------------------------------------------------------------------------
/src/services/index.ts:
--------------------------------------------------------------------------------
1 | import { createInstance } from './http';
2 |
3 | const apis = {
4 | login: '/api/sso/token',
5 | getProviders: 'GET /api/data/providers',
6 | getSchemas: 'GET /api/data/schemas',
7 | getQueryData: 'GET /api/data/query_data',
8 |
9 | getPools: 'GET /api/work/get_stock_pool_info',
10 | getPoolEntities: 'GET /api/work/get_stock_pools',
11 | getSimpleStockTags: '/api/work/query_simple_stock_tags',
12 | getHistoryStockTags: '/api/work/query_stock_tags',
13 | getStockStats: '/api/work/query_stock_tag_stats',
14 |
15 | createMainTagInfo: '/api/work/create_main_tag_info',
16 | createSubTagInfo: '/api/work/create_sub_tag_info',
17 | createHiddenTagInfo: '/api/work/create_hidden_tag_info',
18 |
19 | getMainTagInfo: 'GET /api/work/get_main_tag_info',
20 | getSubTagInfo: 'GET /api/work/get_sub_tag_info',
21 | getHiddenTagInfo: 'GET /api/work/get_hidden_tag_info',
22 |
23 | updateStockTags: '/api/work/set_stock_tags',
24 | getStockEvents: 'GET /api/event/get_stock_event',
25 | getTagsStats: '/api/trading/query_tag_quotes',
26 | ignoreStockNews: '/api/event/ignore_stock_news',
27 |
28 | getFactors: 'GET /api/factor/get_factors',
29 | getFactorResult: '/api/factor/query_factor_result',
30 |
31 | getPoolSetting: 'GET /api/trading/get_query_stock_quote_setting',
32 | getPoolStocksByTag: '/api/trading/query_stock_quotes',
33 | savePoolSetting: '/api/trading/build_query_stock_quote_setting',
34 |
35 | getSuggestionStats: 'GET /api/event/get_tag_suggestions_stats',
36 | getNewsAnalysis: 'GET /api/event/get_stock_news_analysis',
37 | batchUpdateStockTags: '/api/work/batch_set_stock_tags',
38 | buildTagSuggestions: '/api/event/build_tag_suggestions',
39 |
40 | buyStocks: '/api/trading/buy',
41 | sellStocks: '/api/trading/sell',
42 |
43 | getStockTagOptions: 'GET /api/work/get_stock_tag_options',
44 | getTimeMessage: 'GET /api/misc/time_message',
45 | getDailyQuoteStats: 'GET /api/trading/get_quote_stats',
46 |
47 | getKData: '/api/trading/query_kdata',
48 | getTData: '/api/trading/query_ts',
49 | } as const;
50 |
51 | const instance = createInstance({
52 | apis,
53 | });
54 |
55 | export default instance;
56 |
--------------------------------------------------------------------------------
/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | import dayjs from 'dayjs';
2 |
3 | let currentIndex = 1;
4 | export const getSimpleId = () => {
5 | return currentIndex++;
6 | };
7 |
8 | export function getDate(date?: any) {
9 | return dayjs(date).format('YYYY-MM-DD');
10 | }
11 |
12 | export function toMoney(value: number, digits = 2) {
13 | const sign = Math.sign(value);
14 | value = Math.abs(value || 0);
15 | if (value >= 10000 * 10000) {
16 | return ((sign * value) / (10000 * 10000)).toFixed(digits) + '亿';
17 | }
18 |
19 | if (value >= 10000) {
20 | return ((sign * value) / 10000).toFixed(digits) + '万';
21 | }
22 |
23 | return sign * value;
24 | }
25 |
26 | export function toPercent(value: number, nx = 2) {
27 | return ((value || 0) * 100).toFixed(nx) + '%';
28 | }
29 |
30 | export function toTradePercent(value: number, nx = 2) {
31 | const percentText = toPercent(value, nx);
32 | return value > 0 ? '+' + percentText : percentText;
33 | }
34 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from 'tailwindcss';
2 |
3 | const config: Config = {
4 | content: [
5 | './src/pages/**/*.{js,ts,jsx,tsx,mdx}',
6 | './src/components/**/*.{js,ts,jsx,tsx,mdx}',
7 | './src/app/**/*.{js,ts,jsx,tsx,mdx}',
8 | ],
9 | theme: {
10 | extend: {
11 | backgroundImage: {
12 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
13 | 'gradient-conic':
14 | 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
15 | },
16 | width: {
17 | container: 'calc(100% - 72px)',
18 | },
19 | lineHeight: {
20 | '11': '2.75rem',
21 | '12': '3rem',
22 | '13': '3.25rem',
23 | '14': '3.5rem',
24 | '15': '3.75rem',
25 | '16': '4rem',
26 | },
27 | colors: {
28 | primary: '#0B6BCB',
29 | },
30 | },
31 | },
32 | plugins: [],
33 | };
34 | export default config;
35 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": true,
7 | "noEmit": true,
8 | "esModuleInterop": true,
9 | "module": "esnext",
10 | "moduleResolution": "bundler",
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "downlevelIteration": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./src/*"],
23 | "@mui/material": ["./node_modules/@mui/joy"]
24 | }
25 | },
26 | "include": [
27 | "next-env.d.ts",
28 | "typings.d.ts",
29 | "**/*.ts",
30 | "**/*.tsx",
31 | ".next/types/**/*.ts"
32 | ],
33 | "exclude": ["node_modules"]
34 | }
35 |
--------------------------------------------------------------------------------
/typings.d.ts:
--------------------------------------------------------------------------------
1 | declare global {
2 | interface Window {
3 | SERVER_HOST: string;
4 | }
5 | }
6 |
--------------------------------------------------------------------------------