feat: 初始提交(仅核心代码,已排除大文件)

This commit is contained in:
yangxiangyuan
2026-07-08 10:29:50 +08:00
commit be7c5842f1
623 changed files with 332897 additions and 0 deletions
+91
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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 {}
}
})