fix: K线迁移 + 清理重复文件

migrate_all.py:
- 新增 migrate_klines(): multi_tf_cache.json → stock_daily/weekly/monthly + stock_fundamentals
- 迁移量: daily=5520, weekly=1104, monthly=552 (46只股票)
- 验证表新增 stock_daily/weekly/monthly/fundamentals

清理:
- 删除 web/static/ (与根目录 static/ 重复,server.py 使用 static/)
This commit is contained in:
hmo
2026-06-20 20:45:30 +08:00
parent 5936db828f
commit c80f814632
3 changed files with 94 additions and 1354 deletions
+94 -1
View File
@@ -479,6 +479,88 @@ def migrate_strategy_feedback(conn, sf: dict) -> int:
return count
def migrate_klines(conn) -> tuple[int, int, int]:
"""multi_tf_cache.json → stock_daily + stock_weekly + stock_monthly + stock_fundamentals"""
data = load_json("multi_tf_cache.json")
if not data:
return 0, 0, 0
daily_count, weekly_count, monthly_count = 0, 0, 0
for code, stock in data.items():
code = _normalize_code(code)
if not code or code.startswith("_"):
continue
name = code # 从 stocks 表或 profiles 获取名称
try:
row = conn.execute("SELECT name FROM stocks WHERE code = ?", (code,)).fetchone()
if row:
name = row[0]
except Exception:
pass
# K线数据
for period, table, key in [
("daily", "stock_daily", "daily"),
("weekly", "stock_weekly", "weekly"),
("monthly", "stock_monthly", "monthly"),
]:
bars = stock.get(key, [])
if not bars or isinstance(bars, dict):
continue
rows = []
for b in bars:
if not isinstance(b, dict):
continue
d = b.get("date", "")
if not d:
continue
if period == "daily":
rows.append((code, d, b.get("open"), b.get("close"),
b.get("high"), b.get("low"), b.get("volume"),
b.get("amount")))
else:
rows.append((code, d, b.get("open"), b.get("close"),
b.get("high"), b.get("low"), b.get("volume")))
if rows:
try:
if period == "daily":
conn.executemany(
"INSERT OR REPLACE INTO stock_daily (code, date, open, close, high, low, volume, amount) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)", rows)
daily_count += len(rows)
elif period == "weekly":
conn.executemany(
"INSERT OR REPLACE INTO stock_weekly (code, date, open, close, high, low, volume) "
"VALUES (?, ?, ?, ?, ?, ?, ?)", rows)
weekly_count += len(rows)
else:
conn.executemany(
"INSERT OR REPLACE INTO stock_monthly (code, date, open, close, high, low, volume) "
"VALUES (?, ?, ?, ?, ?, ?, ?)", rows)
monthly_count += len(rows)
except Exception:
pass
# 基本面
fundamentals = stock.get("fundamentals")
if fundamentals and isinstance(fundamentals, dict):
try:
conn.execute(
"INSERT OR REPLACE INTO stock_fundamentals (code, pe, pb, eps, mcap_total, mcap_flow, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(code, fundamentals.get("pe"), fundamentals.get("pb"),
fundamentals.get("eps"), fundamentals.get("mcap_total"),
fundamentals.get("mcap_flow"), datetime.now().isoformat()))
except Exception:
pass
conn.commit()
return daily_count, weekly_count, monthly_count
def main():
print("=" * 60)
print(" MoFin 数据迁移 → mofin.db")
@@ -572,7 +654,14 @@ def main():
if sf:
n = migrate_strategy_feedback(conn, sf)
totals["strategy_feedback"] = n
print(f"[12/12] strategy_feedback: {n}")
print(f"[12/13] strategy_feedback: {n}")
# 13. K线数据
dc, wc, mc = migrate_klines(conn)
totals["daily_klines"] = dc
totals["weekly_klines"] = wc
totals["monthly_klines"] = mc
print(f"[13/13] K-lines: daily={dc}, weekly={wc}, monthly={mc}")
conn.commit()
@@ -594,6 +683,10 @@ def main():
"advice_timeline": "SELECT COUNT(*) FROM advice_timeline",
"accuracy_stats": "SELECT COUNT(*) FROM accuracy_stats",
"strategy_feedback": "SELECT COUNT(*) FROM strategy_feedback",
"stock_daily": "SELECT COUNT(*) FROM stock_daily",
"stock_weekly": "SELECT COUNT(*) FROM stock_weekly",
"stock_monthly": "SELECT COUNT(*) FROM stock_monthly",
"stock_fundamentals": "SELECT COUNT(*) FROM stock_fundamentals",
}
for name, sql in tables.items():
n = conn.execute(sql).fetchone()[0]
File diff suppressed because it is too large Load Diff
-247
View File
@@ -1,247 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MoFin - 持仓截图解析</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family: -apple-system, 'PingFang SC', sans-serif; background: #0f0f1a; color: #e0e0e0; min-height: 100vh; display: flex; justify-content: center; align-items: flex-start; padding: 40px 20px; }
.container { max-width: 800px; width: 100%; }
h1 { font-size: 22px; margin-bottom: 8px; color: #00d4ff; }
.sub { color: #888; font-size: 13px; margin-bottom: 30px; }
.upload-box { border: 2px dashed #333; border-radius: 12px; padding: 40px; text-align: center; cursor: pointer; transition: all .3s; background: #1a1a2e; }
.upload-box:hover { border-color: #00d4ff; background: #1e1e35; }
.upload-box.dragover { border-color: #00d4ff; background: #1a1a3e; }
.upload-box .icon { font-size: 48px; margin-bottom: 12px; }
.upload-box .hint { color: #888; font-size: 13px; margin-top: 8px; }
input[type=file] { display: none; }
#preview { display: none; max-width: 100%; max-height: 400px; margin: 15px auto; border-radius: 8px; }
#loading { display: none; text-align: center; padding: 20px; }
#loading .spinner { width: 40px; height: 40px; border: 3px solid #333; border-top-color: #00d4ff; border-radius: 50%; animation: spin .8s linear infinite; margin: 0 auto 12px; }
@keyframes spin { to { transform: rotate(360deg); } }
#result { display: none; margin-top: 24px; }
.result-box { background: #1a1a2e; border-radius: 12px; padding: 20px; }
.result-box h3 { color: #00d4ff; font-size: 16px; margin-bottom: 15px; }
.result-box .type-badge { display: inline-block; padding: 2px 10px; border-radius: 4px; font-size: 12px; margin-bottom: 15px; }
.badge-portfolio { background: #1a3a3a; color: #00ff88; }
.badge-watchlist { background: #3a2a1a; color: #ffaa00; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { padding: 8px 10px; text-align: left; border-bottom: 1px solid #222; }
th { color: #888; font-weight: 500; white-space: nowrap; }
tr:hover td { background: #222; }
.actions { margin-top: 20px; display: flex; gap: 10px; }
.btn { padding: 10px 24px; border: none; border-radius: 8px; font-size: 14px; cursor: pointer; transition: all .2s; }
.btn-primary { background: #00d4ff; color: #000; }
.btn-primary:hover { background: #00b8e0; }
.btn-secondary { background: #333; color: #e0e0e0; }
.btn-secondary:hover { background: #444; }
.btn-danger { background: #d63031; color: #fff; }
.btn-danger:hover { background: #b22020; }
#error { display: none; background: #2a1a1a; border: 1px solid #d63031; border-radius: 8px; padding: 15px; margin-top: 15px; color: #ff6b6b; font-size: 14px; }
</style>
</head>
<body>
<div class="container">
<h1><a href="/" style="text-decoration:none;color:#00d4ff">📸 持仓截图解析</a></h1>
<p class="sub"><a href="/" style="color:#666;text-decoration:none">← 返回仪表盘</a> · 上传持仓截图或自选截图,知微自动识别并更新数据</p>
<div class="upload-box" id="dropZone" onclick="document.getElementById('fileInput').click()">
<div class="icon">📤</div>
<div>点击上传、拖拽图片、或 Ctrl+V 粘贴</div>
<div class="hint">支持 PNG / JPG / JPEG,建议清晰截图</div>
</div>
<input type="file" id="fileInput" accept="image/png,image/jpeg,image/jpg">
<img id="preview">
<div id="loading">
<div class="spinner"></div>
<div>🔍 知微正在分析图片...</div>
</div>
<div id="error"></div>
<div id="result">
<div class="result-box">
<span class="type-badge" id="typeBadge">持仓</span>
<h3 id="resultTitle"></h3>
<div id="summaryBox"></div>
<div id="resultTable"></div>
<div class="actions">
<button class="btn btn-primary" onclick="confirmUpdate()">✅ 确认更新</button>
<button class="btn btn-secondary" onclick="resetPage()">🔄 重新上传</button>
</div>
</div>
</div>
<div id="success" style="display:none">
<div class="result-box" style="text-align:center;padding:30px">
<div style="font-size:48px;margin-bottom:12px"></div>
<h3 style="color:#00ff88">数据已更新</h3>
<p id="successMsg" style="color:#888;margin:8px 0"></p>
<button class="btn btn-primary" onclick="resetPage()" style="margin-top:15px">📸 上传下一张</button>
<a href="/" class="btn btn-secondary" style="display:inline-block;margin-top:10px;text-decoration:none">📊 返回仪表盘</a>
</div>
</div>
</div>
<script>
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const preview = document.getElementById('preview');
const loading = document.getElementById('loading');
const result = document.getElementById('result');
const success = document.getElementById('success');
const error = document.getElementById('error');
let lastResult = null;
// Drag & drop
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('dragover'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
dropZone.addEventListener('drop', e => { e.preventDefault(); dropZone.classList.remove('dragover'); handleFile(e.dataTransfer.files[0]); });
// Paste support (截图后直接Ctrl+V)
document.addEventListener('paste', e => {
const items = e.clipboardData?.items;
if (!items) return;
for (const item of items) {
if (item.type.startsWith('image/')) {
e.preventDefault();
const blob = item.getAsFile();
if (blob) handleFile(blob);
break;
}
}
});
fileInput.addEventListener('change', e => handleFile(e.target.files[0]));
function handleFile(file) {
if (!file) return;
if (!file.type.startsWith('image/')) { showError('请上传图片文件'); return; }
if (file.size > 10 * 1024 * 1024) { showError('图片太大,请小于10MB'); return; }
// Show preview
const reader = new FileReader();
reader.onload = e => {
preview.src = e.target.result;
preview.style.display = 'block';
dropZone.style.display = 'none';
};
reader.readAsDataURL(file);
// Upload
loading.style.display = 'block';
error.style.display = 'none';
result.style.display = 'none';
const form = new FormData();
form.append('image', file);
fetch('/api/upload/analyze', { method: 'POST', body: form })
.then(r => r.json())
.then(data => {
loading.style.display = 'none';
if (data.error) { showError(data.error); return; }
lastResult = data;
showResult(data);
})
.catch(err => {
loading.style.display = 'none';
showError('上传失败: ' + err.message);
});
}
// 格式化数字
function formatNum(n) {
if (!n) return '-';
const num = parseFloat(n);
if (isNaN(num)) return n;
return num.toLocaleString('zh-CN', {minimumFractionDigits: 2, maximumFractionDigits: 2});
}
function showResult(data) {
result.style.display = 'block';
const badge = document.getElementById('typeBadge');
const title = document.getElementById('resultTitle');
const table = document.getElementById('resultTable');
const summaryBox = document.getElementById('summaryBox');
const isPortfolio = data.type === 'portfolio';
badge.className = 'type-badge ' + (isPortfolio ? 'badge-portfolio' : 'badge-watchlist');
badge.textContent = isPortfolio ? '📊 持仓截图' : '⭐ 自选截图';
title.textContent = `解析出 ${data.stocks.length} 只股票`;
// 显示汇总数据
const s = data.summary || {};
let sumHtml = '';
if (isPortfolio && (s.total_assets || s.stock_value || s.cash || s.day_pnl)) {
sumHtml = '<div style="display:flex;flex-wrap:wrap;gap:12px;margin-bottom:15px;padding:12px;background:#151525;border-radius:8px">';
if (s.total_assets) sumHtml += `<div style="flex:1;min-width:120px"><div style="color:#888;font-size:12px">总资产</div><div style="font-size:18px;font-weight:600;color:#00d4ff">${formatNum(s.total_assets)}</div></div>`;
if (s.stock_value) sumHtml += `<div style="flex:1;min-width:120px"><div style="color:#888;font-size:12px">股票市值</div><div style="font-size:18px;font-weight:600;color:#e0e0e0">${formatNum(s.stock_value)}</div></div>`;
if (s.cash) sumHtml += `<div style="flex:1;min-width:120px"><div style="color:#888;font-size:12px">可用资金</div><div style="font-size:18px;font-weight:600;color:#e0e0e0">${formatNum(s.cash)}</div></div>`;
if (s.day_pnl) sumHtml += `<div style="flex:1;min-width:120px"><div style="color:#888;font-size:12px">当日盈亏</div><div style="font-size:18px;font-weight:600;color:${(s.day_pnl||'').includes('-')?'#ff6b6b':'#00ff88'}">${formatNum(s.day_pnl)}</div></div>`;
sumHtml += '</div>';
} else if (isPortfolio) {
sumHtml = '<div style="padding:12px;margin-bottom:15px;background:#151525;border-radius:8px;color:#888;font-size:13px">⚠️ 未识别到总资产/现金等汇总数据,确认更新后将保留原有数据</div>';
}
summaryBox.innerHTML = sumHtml;
let html = '<table><thead><tr><th>代码</th><th>名称</th><th>数量</th><th>现价</th><th>成本</th><th>盈亏</th><th>仓位%</th></tr></thead><tbody>';
data.stocks.forEach(s => {
html += `<tr>
<td>${s.code || '-'}</td>
<td>${s.name || '-'}</td>
<td>${s.shares || '-'}</td>
<td>${s.price || '-'}</td>
<td>${s.cost || '-'}</td>
<td style="color:${(s.pnl||'').includes('+')?'#00ff88':'#ff6b6b'}">${s.pnl || '-'}</td>
<td>${s.position_pct || '-'}</td>
</tr>`;
});
html += '</tbody></table>';
table.innerHTML = html;
}
function confirmUpdate() {
if (!lastResult) return;
loading.style.display = 'block';
fetch('/api/upload/confirm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(lastResult)
})
.then(r => r.json())
.then(data => {
loading.style.display = 'none';
if (data.error) { showError(data.error); return; }
result.style.display = 'none';
document.getElementById('successMsg').textContent = data.message;
success.style.display = 'block';
})
.catch(err => {
loading.style.display = 'none';
showError('更新失败: ' + err.message);
});
}
function showError(msg) {
error.textContent = msg;
error.style.display = 'block';
}
function resetPage() {
preview.style.display = 'none';
dropZone.style.display = 'block';
result.style.display = 'none';
success.style.display = 'none';
error.style.display = 'none';
loading.style.display = 'none';
lastResult = null;
fileInput.value = '';
preview.src = '';
}
</script>
</body>
</html>