feat: 初始提交(仅核心代码,已排除大文件)
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>投资事件账本</title>
|
||||
<link rel="stylesheet" href="./investment_ledger.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="page">
|
||||
<section class="panel">
|
||||
<h1>投资事件账本 V1</h1>
|
||||
<div class="form-grid">
|
||||
<label>CSV 文件路径</label>
|
||||
<input id="filePath" type="text" value="d:\Trae_Files\TRAE-Toolbox\temp\工作簿1.csv" />
|
||||
<label>标的代码</label>
|
||||
<input id="symbol" type="text" value="510500" />
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button id="btnParse">开始解析</button>
|
||||
<button id="btnAdd">新增事件</button>
|
||||
<button id="btnExport" disabled>导出结果CSV</button>
|
||||
</div>
|
||||
<div id="status" class="status"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>汇总</h2>
|
||||
<div id="summary" class="summary"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>事件明细</h2>
|
||||
<div class="table-wrap">
|
||||
<table id="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>事件ID</th>
|
||||
<th>时间</th>
|
||||
<th>标的</th>
|
||||
<th>类型</th>
|
||||
<th>方向</th>
|
||||
<th>价格</th>
|
||||
<th>数量</th>
|
||||
<th>金额</th>
|
||||
<th>仓位池</th>
|
||||
<th>策略</th>
|
||||
<th>链路</th>
|
||||
<th>状态</th>
|
||||
<th>来源行</th>
|
||||
<th>备注</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="legend" class="legend"></div>
|
||||
</section>
|
||||
</main>
|
||||
<div id="modalWrap" class="modal-wrap">
|
||||
<div class="modal">
|
||||
<div class="modal-head">
|
||||
<h3 id="modalTitle">新增事件</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="modal-grid">
|
||||
<label>时间</label><input id="m_event_time" type="date" />
|
||||
<label>标的</label><input id="m_symbol" type="text" />
|
||||
<label>事件类型</label><input id="m_event_type" type="text" value="fill" />
|
||||
<label>方向</label><input id="m_side" type="text" value="buy" />
|
||||
<label>价格</label><input id="m_price" type="number" step="0.001" />
|
||||
<label>数量</label><input id="m_qty" type="number" step="1" />
|
||||
<label>金额</label><input id="m_amount" type="number" step="0.01" />
|
||||
<label>仓位池</label><input id="m_pocket" type="text" value="swing" />
|
||||
<label>策略</label><input id="m_strategy_id" type="text" value="MANUAL_DIARY_v1" />
|
||||
<label>链路</label><input id="m_chain_id" type="text" value="X" />
|
||||
<label>状态</label><input id="m_status" type="text" value="filled" />
|
||||
<label>父事件</label><input id="m_parent_event_ids" type="text" />
|
||||
<label>备注</label><input id="m_note" type="text" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button id="btnModalCancel" class="btn-secondary">取消</button>
|
||||
<button id="btnModalSave">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="./investment_ledger.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,181 @@
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
background: #f4f6fb;
|
||||
color: #111827;
|
||||
}
|
||||
.page {
|
||||
max-width: 1400px;
|
||||
margin: 20px auto;
|
||||
padding: 0 16px 24px;
|
||||
}
|
||||
.panel {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
h1, h2 {
|
||||
margin: 0 0 12px 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.actions {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
button {
|
||||
border: 1px solid #2563eb;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
padding: 9px 14px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button[disabled] {
|
||||
opacity: .45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn-secondary {
|
||||
border-color: #94a3b8;
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
}
|
||||
.status {
|
||||
margin-top: 10px;
|
||||
min-height: 22px;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
.summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.card .k {
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
}
|
||||
.card .v {
|
||||
margin-top: 4px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.table-wrap {
|
||||
overflow: auto;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 8px 9px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
white-space: nowrap;
|
||||
}
|
||||
td:last-child {
|
||||
max-width: 420px;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
.row-merged {
|
||||
background: #fef3c7;
|
||||
}
|
||||
.row-multi {
|
||||
background: #dbeafe;
|
||||
}
|
||||
.cell-op {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mini-btn {
|
||||
border: 1px solid #2563eb;
|
||||
background: #fff;
|
||||
color: #1d4ed8;
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
.mini-btn.del {
|
||||
border-color: #dc2626;
|
||||
color: #dc2626;
|
||||
}
|
||||
.legend {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: #334155;
|
||||
}
|
||||
.legend .chip {
|
||||
display: inline-block;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.legend .chip.merged { background: #fef3c7; }
|
||||
.legend .chip.multi { background: #dbeafe; }
|
||||
.modal-wrap {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 50;
|
||||
}
|
||||
.modal-wrap.show {
|
||||
display: flex;
|
||||
}
|
||||
.modal {
|
||||
width: min(920px, calc(100vw - 32px));
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.modal-head {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
.modal-head h3 {
|
||||
margin: 0;
|
||||
}
|
||||
.modal-body {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.modal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 90px 1fr 90px 1fr;
|
||||
gap: 10px 12px;
|
||||
align-items: center;
|
||||
}
|
||||
.modal-actions {
|
||||
border-top: 1px solid #e2e8f0;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
const state = { rows: [], editingIndex: -1 }
|
||||
const LS_KEY = 'investment_ledger_rows_v1'
|
||||
|
||||
const $ = id => document.getElementById(id)
|
||||
|
||||
const setStatus = (msg, isError) => {
|
||||
const el = $('status')
|
||||
if (!el) return
|
||||
el.textContent = msg || ''
|
||||
el.style.color = isError ? '#dc2626' : '#1d4ed8'
|
||||
}
|
||||
|
||||
const fmt = n => Number(n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
|
||||
const esc = s => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
|
||||
const numOrNull = v => {
|
||||
const n = Number(v)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
const computeSummary = rows => {
|
||||
let buy = 0
|
||||
let sell = 0
|
||||
let pending = 0
|
||||
for (const r of rows) {
|
||||
if (r.status === 'pending') pending += 1
|
||||
if (r.event_type !== 'fill') continue
|
||||
const amt = Number(r.amount || 0)
|
||||
if (r.side === 'buy') buy += amt
|
||||
if (r.side === 'sell') sell += amt
|
||||
}
|
||||
return {
|
||||
total_events: rows.length,
|
||||
pending_orders: pending,
|
||||
filled_buy_amount: Number(buy.toFixed(2)),
|
||||
filled_sell_amount: Number(sell.toFixed(2)),
|
||||
net_cashflow: Number((sell - buy).toFixed(2))
|
||||
}
|
||||
}
|
||||
|
||||
const renderSummary = s => {
|
||||
const el = $('summary')
|
||||
if (!el) return
|
||||
const cards = [
|
||||
{ k: '事件总数', v: Number(s.total_events || 0).toLocaleString('zh-CN') },
|
||||
{ k: '待成交预设', v: Number(s.pending_orders || 0).toLocaleString('zh-CN') },
|
||||
{ k: '买入成交金额', v: fmt(s.filled_buy_amount) },
|
||||
{ k: '卖出成交金额', v: fmt(s.filled_sell_amount) },
|
||||
{ k: '净现金流', v: fmt(s.net_cashflow) }
|
||||
]
|
||||
el.innerHTML = cards.map(c => `<div class="card"><div class="k">${c.k}</div><div class="v">${c.v}</div></div>`).join('')
|
||||
}
|
||||
|
||||
const getHighlightMap = rows => {
|
||||
const multiMap = new Map()
|
||||
for (const r of rows) {
|
||||
const key = `${r.event_time || ''}|${r.strategy_id || ''}`
|
||||
if (!r.event_time || !r.strategy_id) continue
|
||||
multiMap.set(key, (multiMap.get(key) || 0) + 1)
|
||||
}
|
||||
return { multiMap }
|
||||
}
|
||||
|
||||
const renderTable = rows => {
|
||||
const { multiMap } = getHighlightMap(rows)
|
||||
const tbody = $('tbl').querySelector('tbody')
|
||||
tbody.innerHTML = rows.map((r, idx) => {
|
||||
const price = r.fill_price || r.order_price || ''
|
||||
const merged = !!(r.parent_event_ids || String(r.chain_id || '').startsWith('M'))
|
||||
const multi = (multiMap.get(`${r.event_time || ''}|${r.strategy_id || ''}`) || 0) > 1
|
||||
const cls = `${merged ? 'row-merged' : ''} ${multi ? 'row-multi' : ''}`.trim()
|
||||
return `<tr class="${cls}">
|
||||
<td>${esc(r.event_id || '')}</td>
|
||||
<td>${esc(r.event_time || '')}</td>
|
||||
<td>${esc(r.symbol || '')}</td>
|
||||
<td>${esc(r.event_type || '')}</td>
|
||||
<td>${esc(r.side || '')}</td>
|
||||
<td>${esc(price)}</td>
|
||||
<td>${esc(r.qty || '')}</td>
|
||||
<td>${esc(r.amount || '')}</td>
|
||||
<td>${esc(r.pocket || '')}</td>
|
||||
<td>${esc(r.strategy_id || '')}</td>
|
||||
<td>${esc(r.chain_id || '')}</td>
|
||||
<td>${esc(r.status || '')}</td>
|
||||
<td>${esc(r.source_line_no || '')}</td>
|
||||
<td>${esc(r.note || '')}</td>
|
||||
<td class="cell-op">
|
||||
<button class="mini-btn js-edit" data-idx="${idx}">修改</button>
|
||||
<button class="mini-btn del js-del" data-idx="${idx}">删除</button>
|
||||
</td>
|
||||
</tr>`
|
||||
}).join('')
|
||||
const legend = $('legend')
|
||||
if (legend) legend.innerHTML = '<span class="chip merged">合并仓高亮</span><span class="chip multi">同日多次规则高亮</span>'
|
||||
}
|
||||
|
||||
const toCsvCell = v => {
|
||||
const s = String(v ?? '')
|
||||
if (/[",\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`
|
||||
return s
|
||||
}
|
||||
|
||||
const exportCsv = () => {
|
||||
if (!state.rows.length) return
|
||||
const cols = ['event_id', 'event_time', 'symbol', 'name', 'strategy_id', 'pocket', 'chain_id', 'event_type', 'side', 'order_price', 'fill_price', 'qty', 'amount', 'trigger_rule', 'status', 'parent_event_ids', 'source_line_no', 'note']
|
||||
const lines = [cols.join(',')]
|
||||
for (const row of state.rows) lines.push(cols.map(c => toCsvCell(row[c])).join(','))
|
||||
const bom = '\uFEFF'
|
||||
const blob = new Blob([bom + lines.join('\n')], { type: 'text/csv;charset=utf-8;' })
|
||||
const a = document.createElement('a')
|
||||
const now = new Date()
|
||||
const stamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`
|
||||
a.href = URL.createObjectURL(blob)
|
||||
a.download = `investment_ledger_${stamp}.csv`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(a.href)
|
||||
}
|
||||
|
||||
const saveLocal = () => {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(state.rows))
|
||||
}
|
||||
|
||||
const refreshAll = () => {
|
||||
renderSummary(computeSummary(state.rows))
|
||||
renderTable(state.rows)
|
||||
$('btnExport').disabled = state.rows.length === 0
|
||||
saveLocal()
|
||||
}
|
||||
|
||||
const emptyRow = () => ({
|
||||
event_id: '',
|
||||
event_time: '',
|
||||
symbol: $('symbol').value.trim(),
|
||||
name: '',
|
||||
strategy_id: 'MANUAL_DIARY_v1',
|
||||
pocket: 'swing',
|
||||
chain_id: 'X',
|
||||
event_type: 'fill',
|
||||
side: 'buy',
|
||||
order_price: null,
|
||||
fill_price: null,
|
||||
qty: null,
|
||||
amount: null,
|
||||
trigger_rule: '',
|
||||
status: 'filled',
|
||||
parent_event_ids: '',
|
||||
source_line_no: '',
|
||||
note: ''
|
||||
})
|
||||
|
||||
const openModal = (idx = -1) => {
|
||||
state.editingIndex = idx
|
||||
const isEdit = idx >= 0
|
||||
const row = isEdit ? state.rows[idx] : emptyRow()
|
||||
$('modalTitle').textContent = isEdit ? '修改事件' : '新增事件'
|
||||
$('m_event_time').value = row.event_time || ''
|
||||
$('m_symbol').value = row.symbol || ''
|
||||
$('m_event_type').value = row.event_type || 'fill'
|
||||
$('m_side').value = row.side || ''
|
||||
$('m_price').value = row.fill_price || row.order_price || ''
|
||||
$('m_qty').value = row.qty || ''
|
||||
$('m_amount').value = row.amount || ''
|
||||
$('m_pocket').value = row.pocket || ''
|
||||
$('m_strategy_id').value = row.strategy_id || ''
|
||||
$('m_chain_id').value = row.chain_id || ''
|
||||
$('m_status').value = row.status || ''
|
||||
$('m_parent_event_ids').value = row.parent_event_ids || ''
|
||||
$('m_note').value = row.note || ''
|
||||
$('modalWrap').classList.add('show')
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
$('modalWrap').classList.remove('show')
|
||||
}
|
||||
|
||||
const nextEventId = symbol => {
|
||||
const prefix = `EV${symbol || 'NA'}_`
|
||||
let max = 0
|
||||
for (const r of state.rows) {
|
||||
const id = String(r.event_id || '')
|
||||
if (!id.startsWith(prefix)) continue
|
||||
const n = Number(id.slice(prefix.length))
|
||||
if (Number.isFinite(n) && n > max) max = n
|
||||
}
|
||||
return `${prefix}${String(max + 1).padStart(4, '0')}`
|
||||
}
|
||||
|
||||
const saveModal = () => {
|
||||
const symbol = $('m_symbol').value.trim()
|
||||
const price = numOrNull($('m_price').value)
|
||||
const row = {
|
||||
event_id: '',
|
||||
event_time: $('m_event_time').value.trim(),
|
||||
symbol,
|
||||
name: '',
|
||||
strategy_id: $('m_strategy_id').value.trim(),
|
||||
pocket: $('m_pocket').value.trim(),
|
||||
chain_id: $('m_chain_id').value.trim(),
|
||||
event_type: $('m_event_type').value.trim(),
|
||||
side: $('m_side').value.trim(),
|
||||
order_price: $('m_event_type').value.trim() === 'order_preset' ? price : null,
|
||||
fill_price: $('m_event_type').value.trim() === 'fill' ? price : null,
|
||||
qty: numOrNull($('m_qty').value),
|
||||
amount: numOrNull($('m_amount').value),
|
||||
trigger_rule: '',
|
||||
status: $('m_status').value.trim(),
|
||||
parent_event_ids: $('m_parent_event_ids').value.trim(),
|
||||
source_line_no: 'manual',
|
||||
note: $('m_note').value.trim()
|
||||
}
|
||||
if (!row.event_time || !row.symbol || !row.event_type) {
|
||||
setStatus('请至少填写 时间 / 标的 / 事件类型', true)
|
||||
return
|
||||
}
|
||||
if (state.editingIndex >= 0) {
|
||||
row.event_id = state.rows[state.editingIndex].event_id || nextEventId(symbol)
|
||||
state.rows[state.editingIndex] = row
|
||||
setStatus('修改成功')
|
||||
} else {
|
||||
row.event_id = nextEventId(symbol)
|
||||
state.rows.unshift(row)
|
||||
setStatus('新增成功')
|
||||
}
|
||||
closeModal()
|
||||
refreshAll()
|
||||
}
|
||||
|
||||
const parseFile = async () => {
|
||||
try {
|
||||
setStatus('解析中...')
|
||||
$('btnParse').disabled = true
|
||||
const payload = {
|
||||
file_path: $('filePath').value.trim(),
|
||||
symbol: $('symbol').value.trim()
|
||||
}
|
||||
const resp = await fetch('/api/investment_ledger/parse', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
const data = await resp.json()
|
||||
if (!resp.ok || data.ok === false) throw new Error(data.error || 'parse_failed')
|
||||
state.rows = Array.isArray(data.rows) ? data.rows : []
|
||||
refreshAll()
|
||||
setStatus(`完成:识别 ${state.rows.length} 条事件,编码推断 ${data.encoding}`)
|
||||
} catch (e) {
|
||||
setStatus(`失败:${e.message || e}`, true)
|
||||
} finally {
|
||||
$('btnParse').disabled = false
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
$('btnParse').addEventListener('click', parseFile)
|
||||
$('btnAdd').addEventListener('click', () => openModal(-1))
|
||||
$('btnExport').addEventListener('click', exportCsv)
|
||||
$('btnModalCancel').addEventListener('click', closeModal)
|
||||
$('btnModalSave').addEventListener('click', saveModal)
|
||||
$('modalWrap').addEventListener('click', e => {
|
||||
if (e.target && e.target.id === 'modalWrap') closeModal()
|
||||
})
|
||||
$('tbl').addEventListener('click', e => {
|
||||
const t = e.target
|
||||
if (!(t instanceof HTMLElement)) return
|
||||
if (t.classList.contains('js-edit')) {
|
||||
const idx = Number(t.dataset.idx)
|
||||
if (Number.isFinite(idx)) openModal(idx)
|
||||
return
|
||||
}
|
||||
if (t.classList.contains('js-del')) {
|
||||
const idx = Number(t.dataset.idx)
|
||||
if (!Number.isFinite(idx)) return
|
||||
state.rows.splice(idx, 1)
|
||||
refreshAll()
|
||||
setStatus('删除成功')
|
||||
}
|
||||
})
|
||||
const raw = localStorage.getItem(LS_KEY)
|
||||
if (raw) {
|
||||
try {
|
||||
const rows = JSON.parse(raw)
|
||||
if (Array.isArray(rows)) {
|
||||
state.rows = rows
|
||||
refreshAll()
|
||||
setStatus(`已加载本地数据 ${rows.length} 条`)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user