diff --git a/index.html b/index.html new file mode 100644 index 0000000..8092ce5 --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + 个人仓位量化管理系统 + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..77a73c5 --- /dev/null +++ b/package.json @@ -0,0 +1,48 @@ +{ + "name": "portfolio-manager", + "version": "1.0.0", + "description": "个人仓位量化管理系统", + "main": "src/main/main.js", + "scripts": { + "dev": "vite", + "build": "vite build", + "start": "electron .", + "electron:dev": "concurrently \"vite\" \"wait-on http://localhost:5180 && electron .\"", + "electron:build": "vite build && electron-builder" + }, + "author": "", + "license": "MIT", + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "concurrently": "^8.2.0", + "electron": "^28.0.0", + "electron-builder": "^24.9.0", + "vite": "^5.0.0", + "vue": "^3.4.0", + "wait-on": "^7.2.0" + }, + "dependencies": { + "chart.js": "^4.4.0", + "vue-chartjs": "^5.3.0", + "vue-router": "^4.2.0", + "element-plus": "^2.4.0" + }, + "build": { + "appId": "com.portfolio-manager", + "productName": "个人仓位量化管理系统", + "directories": { + "output": "dist-electron" + }, + "files": [ + "dist/**/*", + "src/main/**/*", + "node_modules/**/*" + ], + "mac": { + "category": "public.app-category.finance" + }, + "win": { + "target": "nsis" + } + } +} diff --git a/src/main/main.js b/src/main/main.js new file mode 100644 index 0000000..2eed948 --- /dev/null +++ b/src/main/main.js @@ -0,0 +1,350 @@ +const { app, BrowserWindow, ipcMain, dialog } = require('electron') +const path = require('path') +const fs = require('fs') + +app.disableHardwareAcceleration() +app.commandLine.appendSwitch('disable-gpu') +app.commandLine.appendSwitch('disable-gpu-sandbox') +app.commandLine.appendSwitch('no-sandbox') + +let mainWindow +let dataStore = { + strategies: [], + scoreRanges: [], + positionRecords: [], + rebalanceHistory: [], + config: {} +} + +let dataFilePath = '' + +function createWindow() { + mainWindow = new BrowserWindow({ + width: 1200, + height: 800, + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: false + } + }) + + if (process.env.NODE_ENV === 'development') { + mainWindow.loadURL('http://localhost:5180') + mainWindow.webContents.openDevTools() + } else { + mainWindow.loadFile(path.join(__dirname, '../../dist/index.html')) + } +} + +function initDataStore() { + try { + const projectPath = path.join(__dirname, '..', '..', '..') + dataFilePath = path.join(projectPath, 'portfolio-data.json') + } catch (error) { + const userDataPath = app.getPath('userData') + dataFilePath = path.join(userDataPath, 'portfolio-data.json') + } + + if (fs.existsSync(dataFilePath)) { + try { + const data = fs.readFileSync(dataFilePath, 'utf8') + dataStore = JSON.parse(data) + } catch (error) { + console.error('加载数据文件失败:', error) + initDefaultData() + } + } else { + initDefaultData() + } +} + +function initDefaultData() { + dataStore = { + strategies: [ + { + id: 1, + name: '默认策略', + description: '系统默认仓位配比策略', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString() + } + ], + scoreRanges: [ + { + id: 1, + strategy_id: 1, + min_score: 0, + max_score: 30, + dividend_ratio: 0.1, + broad_market_ratio: 0.1, + satellite_ratio: 0, + cash_ratio: 0.8, + created_at: new Date().toISOString() + }, + { + id: 2, + strategy_id: 1, + min_score: 30, + max_score: 50, + dividend_ratio: 0.2, + broad_market_ratio: 0.2, + satellite_ratio: 0.1, + cash_ratio: 0.5, + created_at: new Date().toISOString() + }, + { + id: 3, + strategy_id: 1, + min_score: 50, + max_score: 70, + dividend_ratio: 0.3, + broad_market_ratio: 0.3, + satellite_ratio: 0.2, + cash_ratio: 0.2, + created_at: new Date().toISOString() + }, + { + id: 4, + strategy_id: 1, + min_score: 70, + max_score: 100, + dividend_ratio: 0.35, + broad_market_ratio: 0.35, + satellite_ratio: 0.25, + cash_ratio: 0.05, + created_at: new Date().toISOString() + } + ], + positionRecords: [], + rebalanceHistory: [], + config: { + rebalanceThreshold: 20 + } + } +} + +function saveDataStore() { + try { + fs.writeFileSync(dataFilePath, JSON.stringify(dataStore, null, 2), 'utf8') + } catch (error) { + console.error('保存数据文件失败:', error) + } +} + +function getNextId(array) { + if (array.length === 0) return 1 + return Math.max(...array.map(item => item.id)) + 1 +} + +app.whenReady().then(() => { + initDataStore() + createWindow() + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow() + } + }) +}) + +app.on('window-all-closed', () => { + saveDataStore() + if (process.platform !== 'darwin') { + app.quit() + } +}) + +// 策略管理API +ipcMain.handle('get-strategies', () => { + return [...dataStore.strategies].sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at)) +}) + +ipcMain.handle('get-strategy', (event, id) => { + return dataStore.strategies.find(s => s.id === id) || null +}) + +ipcMain.handle('create-strategy', (event, name, description) => { + const newStrategy = { + id: getNextId(dataStore.strategies), + name, + description: description || '', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString() + } + dataStore.strategies.push(newStrategy) + saveDataStore() + return newStrategy.id +}) + +ipcMain.handle('update-strategy', (event, id, name, description) => { + const index = dataStore.strategies.findIndex(s => s.id === id) + if (index !== -1) { + dataStore.strategies[index] = { + ...dataStore.strategies[index], + name, + description: description || '', + updated_at: new Date().toISOString() + } + saveDataStore() + } + return true +}) + +ipcMain.handle('delete-strategy', (event, id) => { + dataStore.strategies = dataStore.strategies.filter(s => s.id !== id) + dataStore.scoreRanges = dataStore.scoreRanges.filter(r => r.strategy_id !== id) + dataStore.positionRecords = dataStore.positionRecords.filter(r => r.strategy_id !== id) + dataStore.rebalanceHistory = dataStore.rebalanceHistory.filter(r => r.strategy_id !== id) + saveDataStore() + return true +}) + +// 评分区间API +ipcMain.handle('get-score-ranges', (event, strategyId) => { + return [...dataStore.scoreRanges] + .filter(r => r.strategy_id === strategyId) + .sort((a, b) => a.min_score - b.min_score) +}) + +ipcMain.handle('save-score-ranges', (event, strategyId, ranges) => { + dataStore.scoreRanges = dataStore.scoreRanges.filter(r => r.strategy_id !== strategyId) + + for (const range of ranges) { + dataStore.scoreRanges.push({ + id: getNextId(dataStore.scoreRanges), + strategy_id: strategyId, + min_score: range.minScore, + max_score: range.maxScore, + dividend_ratio: range.dividendRatio, + broad_market_ratio: range.broadMarketRatio, + satellite_ratio: range.satelliteRatio, + cash_ratio: range.cashRatio, + created_at: new Date().toISOString() + }) + } + + saveDataStore() + return true +}) + +// 仓位记录API +ipcMain.handle('create-position-record', (event, record) => { + const newRecord = { + id: getNextId(dataStore.positionRecords), + strategy_id: record.strategyId, + total_capital: record.totalCapital, + market_score: record.marketScore, + dividend_amount: record.dividendAmount, + broad_market_amount: record.broadMarketAmount, + satellite_amount: record.satelliteAmount, + cash_amount: record.cashAmount, + dividend_ratio: record.dividendRatio, + broad_market_ratio: record.broadMarketRatio, + satellite_ratio: record.satelliteRatio, + cash_ratio: record.cashRatio, + record_date: record.recordDate, + created_at: new Date().toISOString() + } + dataStore.positionRecords.push(newRecord) + saveDataStore() + return newRecord.id +}) + +ipcMain.handle('get-position-records', (event, strategyId, limit = 30) => { + return [...dataStore.positionRecords] + .filter(r => r.strategy_id === strategyId) + .sort((a, b) => new Date(b.record_date) - new Date(a.record_date)) + .slice(0, limit) +}) + +// 调仓历史API +ipcMain.handle('create-rebalance-record', (event, record) => { + const newRecord = { + id: getNextId(dataStore.rebalanceHistory), + strategy_id: record.strategyId, + previous_total_capital: record.previousTotalCapital, + new_total_capital: record.newTotalCapital, + previous_score: record.previousScore, + new_score: record.newScore, + score_difference: record.scoreDifference, + rebalance_threshold: record.rebalanceThreshold, + needs_rebalance: record.needsRebalance ? 1 : 0, + previous_dividend_amount: record.previousDividendAmount, + previous_broad_market_amount: record.previousBroadMarketAmount, + previous_satellite_amount: record.previousSatelliteAmount, + previous_cash_amount: record.previousCashAmount, + new_dividend_amount: record.newDividendAmount, + new_broad_market_amount: record.newBroadMarketAmount, + new_satellite_amount: record.newSatelliteAmount, + new_cash_amount: record.newCashAmount, + created_at: new Date().toISOString() + } + dataStore.rebalanceHistory.push(newRecord) + saveDataStore() + return newRecord.id +}) + +ipcMain.handle('get-rebalance-history', (event, strategyId, limit = 30) => { + return [...dataStore.rebalanceHistory] + .filter(r => r.strategy_id === strategyId) + .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)) + .slice(0, limit) +}) + +// 获取上月仓位记录 +ipcMain.handle('get-last-month-position', (event, strategyId) => { + const records = [...dataStore.positionRecords] + .filter(r => r.strategy_id === strategyId) + .sort((a, b) => new Date(b.record_date) - new Date(a.record_date)) + return records[0] || null +}) + +// 系统配置API +ipcMain.handle('get-config', (event, key) => { + return dataStore.config[key] || null +}) + +ipcMain.handle('save-config', (event, key, value) => { + dataStore.config[key] = value + saveDataStore() + return true +}) + +// 数据库路径配置(用于同步) +ipcMain.handle('select-db-path', async () => { + const result = await dialog.showOpenDialog(mainWindow, { + title: '选择数据文件位置', + buttonLabel: '选择', + properties: ['openFile', 'createDirectory'], + filters: [ + { name: '数据文件', extensions: ['json'] }, + { name: '所有文件', extensions: ['*'] } + ] + }) + + if (result.canceled) { + return null + } + + const selectedPath = result.filePaths[0] + + try { + if (fs.existsSync(selectedPath)) { + const data = fs.readFileSync(selectedPath, 'utf8') + const parsedData = JSON.parse(data) + dataStore = parsedData + dataFilePath = selectedPath + saveDataStore() + } + } catch (error) { + console.error('加载数据文件失败:', error) + } + + return selectedPath +}) + +ipcMain.handle('get-current-db-path', () => { + return dataFilePath +}) diff --git a/src/main/preload.js b/src/main/preload.js new file mode 100644 index 0000000..2865800 --- /dev/null +++ b/src/main/preload.js @@ -0,0 +1,33 @@ +const { contextBridge, ipcRenderer } = require('electron') + +contextBridge.exposeInMainWorld('electronAPI', { + // 策略管理 + getStrategies: () => ipcRenderer.invoke('get-strategies'), + getStrategy: (id) => ipcRenderer.invoke('get-strategy', id), + createStrategy: (name, description) => ipcRenderer.invoke('create-strategy', name, description), + updateStrategy: (id, name, description) => ipcRenderer.invoke('update-strategy', id, name, description), + deleteStrategy: (id) => ipcRenderer.invoke('delete-strategy', id), + + // 评分区间 + getScoreRanges: (strategyId) => ipcRenderer.invoke('get-score-ranges', strategyId), + saveScoreRanges: (strategyId, ranges) => ipcRenderer.invoke('save-score-ranges', strategyId, ranges), + + // 仓位记录 + createPositionRecord: (record) => ipcRenderer.invoke('create-position-record', record), + getPositionRecords: (strategyId, limit) => ipcRenderer.invoke('get-position-records', strategyId, limit), + + // 调仓历史 + createRebalanceRecord: (record) => ipcRenderer.invoke('create-rebalance-record', record), + getRebalanceHistory: (strategyId, limit) => ipcRenderer.invoke('get-rebalance-history', strategyId, limit), + + // 获取上月仓位 + getLastMonthPosition: (strategyId) => ipcRenderer.invoke('get-last-month-position', strategyId), + + // 系统配置 + getConfig: (key) => ipcRenderer.invoke('get-config', key), + saveConfig: (key, value) => ipcRenderer.invoke('save-config', key, value), + + // 数据库路径配置 + selectDbPath: () => ipcRenderer.invoke('select-db-path'), + getCurrentDbPath: () => ipcRenderer.invoke('get-current-db-path') +}) diff --git a/src/renderer/App.vue b/src/renderer/App.vue new file mode 100644 index 0000000..a8c4281 --- /dev/null +++ b/src/renderer/App.vue @@ -0,0 +1,127 @@ + + + + + + + diff --git a/src/renderer/main.js b/src/renderer/main.js new file mode 100644 index 0000000..69cc997 --- /dev/null +++ b/src/renderer/main.js @@ -0,0 +1,13 @@ +import { createApp } from 'vue' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import zhCn from 'element-plus/dist/locale/zh-cn.mjs' +import App from './App.vue' +import router from './router' + +const app = createApp(App) + +app.use(ElementPlus, { locale: zhCn }) +app.use(router) + +app.mount('#app') diff --git a/src/renderer/router/index.js b/src/renderer/router/index.js new file mode 100644 index 0000000..18d2bdb --- /dev/null +++ b/src/renderer/router/index.js @@ -0,0 +1,62 @@ +import { createRouter, createWebHashHistory } from 'vue-router' + +const routes = [ + { + path: '/', + redirect: '/dashboard' + }, + { + path: '/dashboard', + name: 'Dashboard', + component: () => import('@/views/Dashboard.vue'), + meta: { title: '仪表盘' } + }, + { + path: '/strategies', + name: 'Strategies', + component: () => import('@/views/Strategies.vue'), + meta: { title: '策略管理' } + }, + { + path: '/position-calculator', + name: 'PositionCalculator', + component: () => import('@/views/PositionCalculator.vue'), + meta: { title: '仓位计算' } + }, + { + path: '/rebalance', + name: 'Rebalance', + component: () => import('@/views/Rebalance.vue'), + meta: { title: '月度调仓' } + }, + { + path: '/history', + name: 'History', + component: () => import('@/views/History.vue'), + meta: { title: '历史记录' } + }, + { + path: '/charts', + name: 'Charts', + component: () => import('@/views/Charts.vue'), + meta: { title: '仓位图表' } + }, + { + path: '/settings', + name: 'Settings', + component: () => import('@/views/Settings.vue'), + meta: { title: '系统设置' } + } +] + +const router = createRouter({ + history: createWebHashHistory(), + routes +}) + +router.beforeEach((to, _from, next) => { + document.title = `${to.meta.title} - 个人仓位量化管理系统` + next() +}) + +export default router diff --git a/src/renderer/utils/positionCalculator.js b/src/renderer/utils/positionCalculator.js new file mode 100644 index 0000000..09e4429 --- /dev/null +++ b/src/renderer/utils/positionCalculator.js @@ -0,0 +1,98 @@ +export function findScoreRange(score, scoreRanges) { + return scoreRanges.find(range => + score >= range.min_score && score <= range.max_score + ) +} + +export function calculateLinearPosition(score, scoreRanges) { + if (!scoreRanges || scoreRanges.length === 0) { + return null + } + + const sortedRanges = [...scoreRanges].sort((a, b) => a.min_score - b.min_score) + + const currentRange = findScoreRange(score, sortedRanges) + if (!currentRange) { + return null + } + + const totalRatio = currentRange.dividend_ratio + currentRange.broad_market_ratio + + currentRange.satellite_ratio + currentRange.cash_ratio + if (Math.abs(totalRatio - 1) > 0.0001) { + return null + } + + const previousRange = sortedRanges[sortedRanges.indexOf(currentRange) - 1] + + if (!previousRange) { + return { + dividendRatio: currentRange.dividend_ratio, + broadMarketRatio: currentRange.broad_market_ratio, + satelliteRatio: currentRange.satellite_ratio, + cashRatio: currentRange.cash_ratio, + range: currentRange + } + } + + const rangeSize = currentRange.max_score - currentRange.min_score + const progress = (score - currentRange.min_score) / rangeSize + + const ratio = { + dividendRatio: previousRange.dividend_ratio + + (currentRange.dividend_ratio - previousRange.dividend_ratio) * progress, + broadMarketRatio: previousRange.broad_market_ratio + + (currentRange.broad_market_ratio - previousRange.broad_market_ratio) * progress, + satelliteRatio: previousRange.satellite_ratio + + (currentRange.satellite_ratio - previousRange.satellite_ratio) * progress, + cashRatio: previousRange.cash_ratio + + (currentRange.cash_ratio - previousRange.cash_ratio) * progress + } + + ratio.dividendRatio = Math.round(ratio.dividendRatio * 10000) / 10000 + ratio.broadMarketRatio = Math.round(ratio.broadMarketRatio * 10000) / 10000 + ratio.satelliteRatio = Math.round(ratio.satelliteRatio * 10000) / 10000 + ratio.cashRatio = Math.round(ratio.cashRatio * 10000) / 10000 + + const total = ratio.dividendRatio + ratio.broadMarketRatio + ratio.satelliteRatio + ratio.cashRatio + if (Math.abs(total - 1) > 0.0001) { + const diff = 1 - total + ratio.cashRatio = Math.round((ratio.cashRatio + diff) * 10000) / 10000 + } + + return { + ...ratio, + range: currentRange, + previousRange + } +} + +export function calculatePositionAmounts(totalCapital, ratio) { + if (!ratio) return null + + return { + dividendAmount: Math.round(totalCapital * ratio.dividendRatio), + broadMarketAmount: Math.round(totalCapital * ratio.broadMarketRatio), + satelliteAmount: Math.round(totalCapital * ratio.satelliteRatio), + cashAmount: Math.round(totalCapital * ratio.cashRatio) + } +} + +export function checkRebalanceNeeded(previousScore, newScore, threshold) { + const scoreDiff = Math.abs(newScore - previousScore) + return { + needsRebalance: scoreDiff >= threshold, + scoreDifference: scoreDiff, + threshold + } +} + +export function calculateRebalanceDifferences(previousPosition, newPosition) { + if (!previousPosition || !newPosition) return null + + return { + dividendDiff: newPosition.dividendAmount - previousPosition.dividend_amount, + broadMarketDiff: newPosition.broadMarketAmount - previousPosition.broad_market_amount, + satelliteDiff: newPosition.satelliteAmount - previousPosition.satellite_amount, + cashDiff: newPosition.cashAmount - previousPosition.cash_amount + } +} diff --git a/src/renderer/views/Charts.vue b/src/renderer/views/Charts.vue new file mode 100644 index 0000000..2ebe985 --- /dev/null +++ b/src/renderer/views/Charts.vue @@ -0,0 +1,527 @@ + + + + + diff --git a/src/renderer/views/Dashboard.vue b/src/renderer/views/Dashboard.vue new file mode 100644 index 0000000..8311c23 --- /dev/null +++ b/src/renderer/views/Dashboard.vue @@ -0,0 +1,260 @@ + + + + + diff --git a/src/renderer/views/History.vue b/src/renderer/views/History.vue new file mode 100644 index 0000000..d65307f --- /dev/null +++ b/src/renderer/views/History.vue @@ -0,0 +1,231 @@ + + + + + diff --git a/src/renderer/views/PositionCalculator.vue b/src/renderer/views/PositionCalculator.vue new file mode 100644 index 0000000..3935d0b --- /dev/null +++ b/src/renderer/views/PositionCalculator.vue @@ -0,0 +1,351 @@ + + + + + diff --git a/src/renderer/views/Rebalance.vue b/src/renderer/views/Rebalance.vue new file mode 100644 index 0000000..06b1ede --- /dev/null +++ b/src/renderer/views/Rebalance.vue @@ -0,0 +1,463 @@ + + + + + diff --git a/src/renderer/views/Settings.vue b/src/renderer/views/Settings.vue new file mode 100644 index 0000000..befea33 --- /dev/null +++ b/src/renderer/views/Settings.vue @@ -0,0 +1,192 @@ + + + + + diff --git a/src/renderer/views/Strategies.vue b/src/renderer/views/Strategies.vue new file mode 100644 index 0000000..48ab4ec --- /dev/null +++ b/src/renderer/views/Strategies.vue @@ -0,0 +1,428 @@ + + + + + diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..addc160 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,21 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import path from 'path' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src/renderer') + } + }, + base: './', + build: { + outDir: 'dist', + emptyOutDir: true + }, + server: { + port: 5180, + strictPort: true + } +})