293 lines
9.6 KiB
JavaScript
293 lines
9.6 KiB
JavaScript
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 {}
|
|
}
|
|
})
|