前言
呃...
竟不知如何开头...
随便聊一聊吧...
最近公司项目不是很忙,有些空闲时光,想一想事情,想着...想着,就想到一些事情:因为自己平常会以不定时不定额买一些基金,遇到大涨的时候会手动计算单笔基金的预估收益率得到预售单,但时间一长,不好管理还浪费时间。那正好有这个时间,为什么不写个程序代替呢?于是就开发了这个小工具:将基金购买的历史记录用表格存储,再根据输入基金代码和基金价格筛选和计算出盈利的单笔基金。
项目介绍
使用 vitepress 搭建一个静态资源站以可通过网页访问(vitepress 可将 md 文档转为 html 页面),因考虑到一个 npm 包的命令个数有限制(package.json 文件 bin 字段定义了脚本命令,通过 npm link 链接到全局,便可直接使用该命令,但通常这样的命令只有一个),便通过定义脚本命令的方式来实现一些抽离出来的功能(例如:yarn generate
: 该命令用来创建基金文档并写入导航)。
开发过程中涉及文件操作(文件的创建、读取等)、解析命令行参数,以及与用户的交互等。需做好准备工作:熟悉 node 自带的 path 和 fs 库,以及与用户交互的第三方库 prompts 和 解析命令行参数 minimist。
一些构想
(1)确定主线和副线:
- 主线:这个工具主要用来存储基金的购买记录并根据给定的基金估值计算盈利的单子;
- 副线:(1)创建基金文档(
yarn generate
);(2)计算单笔盈利基金(yarn filter
);
(2)把基金购买记录存储在以基金代码为命令的文件里(例如: 162412.md),并且统一放置在 docs/fund 目录下;
(3)考虑到创建基金购买记录文档和在计算后输出到指定文件这两点,便将两者分开,计算输出的文档统一在 docs/preSale 目录下;
(4)考虑到创建基金购买记录文档和在计算后输出到指定文档,两者的列表格式又不同,则创建两个模板文件(index.md和preSale.md),放在 docs/template;
那就开始动工吧~
项目搭建
(1)创建 npm 包: npm init
(2)安装 vitepress: yarn add --dev vitepress
(3)搭建目录结构
└── docs/ // docs 用 vitepress 搭建,静态资源站点
├── .vitepress/ // 配置目录
├── dist // vitepress打包后的目录
├── config.js // vitepress配置目录
├── fund/ // 基金文档列表
├── 161017.md // 基金文档(以基金代码命名)
├── preSale/ // 通用类库目录
├── scripts/ // 脚本目录
├── create.js // 创建一个基金(首先在fund创建基金代码命名的md;其次在.vitepress/config添加到导航;)
├── filter.js // 计算预售基金
├── generate-data.js // 基金买入数据列表(以数组的形式)
├── generate.js // 将generate-data.js的数据转为符合基金代码文档的数据
├── uitls.js // 工具库
├── template/ // 基金模板
├── index.md // 基金买入模板
├── preSale.md // 计算预售模板
├── config.js // 项目配置文件, 存储公共配置数据
├── index.md // 通用 CSS 目录
└── package.json
└── README.md
功能开发
在 package.json 文件定义脚本命令:
{
...
"scripts": {
"docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs",
"docs:serve": "vitepress serve docs",
"generate": "node ./docs/scripts/generate.js",
"filter": "node ./docs/scripts/filter.js"
}
...
}
创建基金文档
持有基金文档格式(docs/template/index.md):
## {{name}}
| 预售日期 | 预售净值 | 交易类型 | 本金 | 买入净值 | 份额 | 买入时间 | 预售金额 | 盈利金额 | 总收益率 | 持有时长 |
| -- | -- | -- | -- | -- | -- | -- | -- | -- | -- | -- |
存储数据
先将基金的购买数据以数组的形式存储在 docs/scripts/generate-data.js 文件中,示例:
// 基金代码:161017
// 格式: | 日期 | 当日净值 | 买卖类型 | 金额 | 份额 | 手续费 | 状态(1:持有, 2:已售) |
export default [
["2022-08-17", "2.5140", "买入", "200", "79.44", "0.3", "持有"],
["2022-08-24", "2.5760", "买入", "200", "77.52","0.3", "持有"],
["2022-08-31", "2.6400", "买入", "200", "75.64", "0.3", "持有"],
]
当然,单条持有基金格式保存在 **docs/config.js**(保存的值是所在下标以在脚本中使用):
export const formatIndex = {
time: 0, // 购买日期
fundValue: 1, // 基金净值
tradeStatus: 2, // 买卖类型
money: 3, // 金额
num: 4, // 份额
serviceCharge: 5, // 手续费
currentStatus: 6, // 状态
}
脚本命令开发过程
命令: yarn generate
, 入口文件 ./docs/scripts/generate.js。当然,命令后第一个参数作为基金code,例如 yarn generate 161017
。
简要逻辑:
- 解析命令行参数, 拿到基金代码;
- 与用户交互: 如果没有拿到基金代码或命令行输入的基金代码参数是无效的,则重新让用户输入,并且做好[校验]
- 至此已拿到基金code, 判断docs/fund/基金code.md该文档是否存在
- 若不存在根据docs/template/index.ts为模板创建一个,然后再将格式化后的数据写入到该文件;
- 若存在, 则将格式化后的数据追加进去
具体代码如下:
// scripts/generate.js
import fs from "node:fs"
import path from "node:path"
import minimist from "minimist"
import prompts from "prompts"
import dataList from "./generate-data.js"
import {
isValidateFundCode,
formatBuyData
} from "./utils.js"
import { PROJECTPATH } from "../config.js"
import configNavList from "../.vitepress/config.js"
const argv = minimist(process.argv.slice(2))
let fundCode = argv._[0] // 第一个参数是基金代码
async function init() {
const hasValidFunCode = fundCode ? isValidateFundCode(fundCode) : false
let hasTargetExist = false
let result = {}
// 与用户交互
try {
result = await prompts([
{
type: hasValidFunCode ? null : "text",
name: "inputFundCode",
message: "请输入由六位数字组成的基金code:",
validate: (value)=> isValidateFundCode(value),
onState: state=> {
hasTargetExist = fs.readdirSync(PROJECTPATH.fund).find(item=> item === `${state.value}.md`)
},
},
{
type: hasTargetExist ? null : 'text',
name: "inputFundName",
message: "请输入基金的中文名称:",
},
])
} catch(err) {
console.log("与用户交互异常:", err)
console.log()
process.exit()
}
// 处理与用户交互拿到的数据
const { inputFundCode, inputFundName } = result
if(inputFundCode) {
fundCode = inputFundCode
}
if(!fundCode || !isValidateFundCode(fundCode)) {
console.log(`${fundCode}基金代码无效!`)
console.log()
process.exit(1)
}
if(!inputFundName) {
console.log(`${fundCode}基金名称为空!`)
console.log()
process.exit(2)
}
// 创建基金文档
const targetPath = path.resolve(PROJECTPATH.fund, `${fundCode}.md`)
const content = formatBuyData(dataList) // 格式化购买数据
const targetContentPreffix = hasTargetExist
? fs.readFileSync(targetPath, "utf-8")
: fs.readFileSync(PROJECTPATH.templateIndex, "utf-8").replace("{{name}}", inputFundName)
fs.writeFileSync(
targetPath,
targetContentPreffix + "\r\n" + content,
"utf-8"
)
if(!hasTargetExist) {
// 修改配置数据(将创建的基金文档写入到配置导航,使在页面上可以访问)
// 但是修改配置数据后需要手动重启docs项目
const configNavPath = path.resolve(PROJECTPATH.configNav)
fs.readFileSync(configNavPath, "utf-8")
const navAddContent = [...(configNavList || [])]
const navIndex = 0
if(navAddContent.length) {
const temp = navAddContent[navIndex].items || []
if(!temp.find(item=> item.text === fundCode)) {
navAddContent[navIndex].items.push({
text: fundCode,
link: `/fund/${fundCode}`
})
}
}
fs.writeFileSync(
configNavPath,
`export default ${JSON.stringify(navAddContent)}`,
"utf-8"
)
}
console.log()
console.log(`已写入文件 ${targetPath}!`)
console.log()
process.exit()
}
console.log()
init().catch(err=> {
console.log("程序异常中断执行:", err)
})
命令结束后,得到一个基金文档 docs/fund/161017.md, 其内容:
## 富国中证500指数增强
| 日期 | 当日净值 | 交易类型 | 金额 | 份额 | 手续费 | 状态 |
| -- | -- | -- | -- | -- | -- | -- |
| 2022-08-17 | 2.5140 | 买入 | ¥200.00 | 79.44份 | ¥0.30 | 持有 |
| 2022-08-24 | 2.5760 | 买入 | ¥200.00 | 77.52份 | ¥0.30 | 持有 |
| 2022-08-31 | 2.6400 | 买入 | ¥200.00 | 75.64份 | ¥0.30 | 持有 |
执行命令:yarn docs:dev
开启本地服务,打开浏览器:
计算盈利记录
输出的预售基金格式(docs/template/preSale.md):
## {{name}}
| 预售日期 | 预售净值 | 交易类型 | 本金 | 买入净值 | 份额 | 买入时间 | 预售金额 | 盈利金额 | 总收益率 | 持有时长 |
| -- | -- | -- | -- | -- | -- | -- | -- | -- | -- | -- |
简要过程阐述:
- 与用户交互,拿到基金code(fundCode)和预估基金净值(inputCurrentPrice);
- 读取
docs/fund/${fundCode}.md
的数据并处理得到一个二维数组(创建购买文档前的准备数据);然后对其 过滤(过滤掉那些购买当日基金净值大于等于 inputCurrentPrice,留下小于 inputCurrentPrice 的数据)和 计算(计算盈利、收益率等),这里过滤和计算过程在方法 formmatPreSaleData 里。 - 最后以
${fundCode}-${new Date().getTime()}.md
输出到 scripts/preSale 目录下。
主要代码:
import {
formatIndex,
PROJECTPATH
} from "../config.js"
import {
isValidateCurrentPrice,
formmatPreSaleData
} from "./utils.js"
const argv = minimist(process.argv.slice(2))
const fundCode = argv._[0] // 第一个参数是基金的代码
const filePreffix = PROJECTPATH.fund
const templatePreffix = PROJECTPATH.template
const targetPreffix = PROJECTPATH.preSale
async function init() {
// 确定基金文档列表
const filesList = fs
.readdirSync(path.resolve(filePreffix))
.filter((item=> item !== 'index.md')) // index.md 是模板,所以需过滤
let fundExisting = fundCode ? filesList.find(item=> item === fundCode + ".md") : false
let result = {}
try {
result = await prompts([
{
type: fundExisting ? null : "select", // 基金选择
name: "selectFund",
message: '请选择基金:',
choices: (filesList || []).map((item=> ({value: item}))),
},
{
type: "text", // 基金净值
name: "inputCurrentPrice",
message: "请输入当前基金净值:",
validate: (value)=> isValidateCurrentPrice(value),
},
])
} catch (err) {
console.log("交互过程中异常:", err)
}
const { selectFund, inputCurrentPrice } = result
fundExisting = fundExisting || selectFund
if(!fundExisting) {
console.log("缺少基金!")
process.exit()
}
console.log("基金代码:", fundExisting);
console.log("基金净值:", inputCurrentPrice)
console.log()
console.log("开始计算中....")
console.log()
const resolvePath = path.resolve(filePreffix, fundExisting)
// 解析 md 文档, 拿到列表数据
const dataList = fs.readFileSync(resolvePath, 'utf-8')
.split(`\r\n`).slice(4)
.filter((item=> item))
.map(item=>
item.replaceAll(" ", "")
.split("|")
.filter(citem=> citem)
)
.filter(item=> parseFloat((item[formatIndex.fundValue] || 0)) < parseFloat(inputCurrentPrice))
const templatePath = path.resolve(templatePreffix, "preSale.md")
const targetContent = formmatPreSaleData(dataList, inputCurrentPrice)
const targetPath = path.resolve(targetPreffix, fundExisting.replace(".md", "-" + new Date().getTime() + ".md"))
console.log("筛选出来的数据:")
console.log()
console.log(targetContent)
console.log()
// 替换名称---
fs.writeFileSync(targetPath,
fs.readFileSync(templatePath, "utf-8").replace("{{name}}", fundExisting.replace(".md", "")) + "\r\n" + targetContent + "\r\n",
"utf-8"
)
console.log(`数据已写入${targetPath}!`)
console.log()
console.log()
console.log("程序运行结束!")
console.log()
process.exit()
}
console.log()
init().catch(err=> {
console.log("程序异常中断执行:", err)
})
formmatPreSaleData 方法具体代码:
...
/**
* 过滤和计算
* @param {Array} list 列表
* @param {String} preSalePrice 基金净值
* @returns Array
*/
export function formmatPreSaleData(list=[], preSalePrice) {
if(!list || !list.length) return ""
const moneyFlag = "¥"
const { time, fundValue, money, num, currentStatus } = formatIndex
const { profitMoney } = formatPreSaleIndex
const dateSplit = "-"
const todyDay = getFormatDate(dateSplit)
return formatDataWrap(list
.filter(item=> item[currentStatus] == "持有")
.map((item=> {
const temp = []
const preSale = towNumMultiplication(preSalePrice, item[num])
temp.push(todyDay) // 预售时间
temp.push(moneyFlag + preSalePrice) // 预售净值
temp.push("预售") // 交易状态 2:预售
temp.push(item[money]) // 本金
temp.push((item[fundValue])) // 购买净值
temp.push(item[num]) // 份额
temp.push((item[time])) // 买入时间
temp.push(moneyFlag + numberToMoney(preSale)) // 售卖金额
temp.push(moneyFlag + numberToMoney(preSale - parseFloat(item[money].replace(moneyFlag, "")))) // 收益
temp.push("0%") // 总收益率
temp.push((countHoldingTime(todyDay, item[time], dateSplit))) // 持有时长
return temp
}))
.sort((a,b)=> parseFloat(a[profitMoney].replace(moneyFlag, "")) > parseFloat(b[profitMoney].replace(moneyFlag, "")))
)
}
命令执行后的得到 docs/preSale/161017-1666858062470.md 文档, 其具体内容:
## 161017
| 预售日期 | 预售净值 | 交易类型 | 本金 | 买入净值 | 份额 | 买入时间 | 预售金额 | 盈利金额 | 总收益率 | 持有时长 |
| -- | -- | -- | -- | -- | -- | -- | -- | -- | -- | -- |
| 2022-10-27 | ¥3.0000 | 预售 | ¥200.00 | 2.5140 | 79.44份 | 2022-08-17 | ¥238.32 | ¥38.32 | 0% | 1天 |
| 2022-10-27 | ¥3.0000 | 预售 | ¥200.00 | 2.5760 | 77.52份 | 2022-08-24 | ¥232.56 | ¥32.56 | 0% | 1天 |
最后
源代码
存放在 github
收获
从思想构建出原型到真正做出来第一个版本,内心还是很有小小的成就感的,继续加油!
在开发的过程中熟悉了(前段时间看vite时用到的一些库,借助于此出了这个工具):
- node:fs 的文件读取操作,配合 node:path 拼成绝对路径 path.resolve();
- 与用户的交互工具 prompts;
- 解析命令行参数工具 minimist 等
没有任何推荐~
只是学习中~