Compare commits

..

4 Commits

Author SHA1 Message Date
fish 19203c050c 将SQLite数据库加入版本控制,方便换设备继续开发
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-25 14:41:35 +08:00
fish 58327a9a66 同步进度增加详细天数反馈,优化日期范围避免重复请求无数据的历史日期
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-25 14:03:33 +08:00
fish ca98967af0 移除全部合约同步按钮,改为单合约后台同步带进度条
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-25 13:53:44 +08:00
fish b74dccd805 持仓同步改为后台执行,添加进度条实时反馈
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-25 00:10:17 +08:00
5 changed files with 223 additions and 36 deletions
+2 -1
View File
@@ -1,3 +1,4 @@
data/
data/*
!data/ft.db
__pycache__/
*.pyc
+133
View File
@@ -1,9 +1,142 @@
"""Data collector — fetch OHLCV from akshare and upsert into daily_bars."""
import threading
from datetime import date
from app.database import SessionLocal
from app.models import DailyBar, Contract
from app.engine.lock_strategy import compute_amp_5d
_progress = {"running": False, "label": "", "done": 0, "total": 0, "finished": False}
_lock = threading.Lock()
def get_progress() -> dict:
with _lock:
return dict(_progress)
def _start_bg(target, label):
with _lock:
if _progress["running"]:
return False
_progress.update(running=True, finished=False, label=label, done=0, total=0)
def _run():
try:
target()
finally:
with _lock:
_progress["running"] = False
_progress["finished"] = True
_progress["done"] = _progress["total"]
threading.Thread(target=_run, daemon=True).start()
return True
def sync_contract_bars_bg(contract_code: str) -> bool:
"""Sync bars for one contract in background. Returns True if started."""
code = contract_code.upper()
def _run():
db = SessionLocal()
try:
latest = (
db.query(DailyBar.date)
.filter(DailyBar.contract == code)
.order_by(DailyBar.date.desc())
.first()
)
start_date = latest[0].isoformat() if latest else None
bars = fetch_contract_bars(code, start_date)
with _lock:
_progress["total"] = len(bars)
total_bars = len(bars)
inserted = 0
min_date = None
for i, bar in enumerate(bars):
existing = (
db.query(DailyBar)
.filter(DailyBar.contract == code, DailyBar.date == bar["date"])
.first()
)
if not existing:
db.add(DailyBar(
contract=code, date=bar["date"],
open=bar["open"], close=bar["close"],
high=bar["high"], low=bar["low"],
))
inserted += 1
if min_date is None or bar["date"] < min_date:
min_date = bar["date"]
with _lock:
_progress["done"] = i + 1
_progress["label"] = f"同步行情 {code} · {i + 1}/{total_bars}"
if inserted > 0:
db.flush()
_recompute_amp(db, code, from_date=min_date)
db.commit()
finally:
db.close()
return _start_bg(_run, f"同步行情 {code}")
def sync_positions_bg(contract_code: str) -> bool:
"""Sync position rankings for one contract in background. Returns True if started."""
from app.models import PositionRanking
code = contract_code.upper()
def _run():
db = SessionLocal()
try:
existing_dates = {
r[0] for r in
db.query(PositionRanking.date)
.filter(PositionRanking.contract_code == code)
.distinct().all()
}
bar_dates = [
r[0] for r in
db.query(DailyBar.date)
.filter(DailyBar.contract == code)
.order_by(DailyBar.date).all()
]
# Only sync dates within or after existing position date range;
# akshare may not have data for very old dates
if existing_dates:
min_pos = min(existing_dates)
missing = [d for d in bar_dates if d not in existing_dates and d >= min_pos]
else:
missing = [d for d in bar_dates if d not in existing_dates]
total_missing = len(missing)
with _lock:
_progress["total"] = total_missing
if total_missing == 0:
_progress["label"] = f"同步持仓 {code} · 已是最新"
inserted = 0
for i, d in enumerate(missing):
rankings = fetch_position_rankings(code, d.strftime("%Y%m%d"))
for r in rankings:
db.add(PositionRanking(
contract_code=code, institution=r["institution"],
data_type=r["data_type"], date=d,
rank=r["rank"], value=r["value"], change=r["change"],
))
inserted += 1
db.flush()
with _lock:
_progress["done"] = i + 1
_progress["label"] = f"同步持仓 {code} · {i + 1}/{total_missing}"
finally:
db.close()
return _start_bg(_run, f"同步持仓 {code}")
def fetch_contract_bars(contract_code: str, start_date: str | None = None) -> list[dict]:
"""Fetch daily OHLCV for a single contract from akshare.
+19 -16
View File
@@ -1,9 +1,11 @@
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import Product, Contract, DailyBar, PositionRanking
from app.collector import sync_active_contracts, sync_one_contract, sync_position_rankings
from app.collector import (
sync_one_contract, sync_contract_bars_bg, sync_positions_bg, get_progress,
)
router = APIRouter(prefix="/admin", tags=["admin"])
@@ -131,26 +133,27 @@ def delete_product(product_id: int, db: Session = Depends(get_db)):
return RedirectResponse("/admin/?tab=product", status_code=303)
@router.post("/sync")
def sync_all(request: Request):
results = sync_active_contracts()
total = sum(results.values())
print(f"[sync] Synced {total} bars across {len(results)} contracts: {results}")
return RedirectResponse(f"/admin/?tab=sync&synced={total}", status_code=303)
@router.post("/sync/{contract_code}")
def sync_single(contract_code: str):
count = sync_one_contract(contract_code.upper())
return RedirectResponse(f"/admin/?tab=sync&synced={count}", status_code=303)
@router.post("/sync-positions")
def sync_positions(request: Request):
results = sync_position_rankings()
total = sum(results.values())
print(f"[sync] Position rankings: {total} rows across {len(results)} contracts")
return RedirectResponse(f"/admin/?tab=sync&pos_synced={total}", status_code=303)
@router.post("/sync/{contract_code}/bg")
def sync_single_bg(contract_code: str):
ok = sync_contract_bars_bg(contract_code.upper())
return JSONResponse({"ok": ok})
@router.post("/sync-positions/{contract_code}/bg")
def sync_positions_bg_ep(contract_code: str):
ok = sync_positions_bg(contract_code.upper())
return JSONResponse({"ok": ok})
@router.get("/sync-status")
def sync_status():
return JSONResponse(get_progress())
@router.post("/sync/product/{product_id}")
+69 -19
View File
@@ -29,21 +29,18 @@
{# ═══════════════════ Tab: 数据同步 ═══════════════════ #}
<div class="tab-panel{% if tab == 'sync' %} active{% endif %}" id="tab-sync">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:20px;padding:14px 18px;background:var(--surface);border:1px solid var(--border);border-radius:10px;">
<span style="font-size:0.85rem;color:var(--sub);">全部合约</span>
<form method="post" action="/admin/sync" style="display:inline;">
<button type="submit" class="btn btn-primary" style="font-size:0.82rem;padding:6px 16px;">同步行情</button>
</form>
<form method="post" action="/admin/sync-positions" style="display:inline;">
<button type="submit" class="btn btn-primary" style="font-size:0.82rem;padding:6px 16px;background:var(--success);">同步持仓</button>
</form>
{% if request.query_params.get('synced') %}
<span style="font-size:0.82rem;color:var(--success-fg);">✓ 行情 {{ request.query_params.synced }} 条</span>
{% endif %}
{% if request.query_params.get('pos_synced') %}
<span style="font-size:0.82rem;color:var(--success-fg);">✓ 持仓 {{ request.query_params.pos_synced }} 条</span>
{% endif %}
<span style="font-size:0.78rem;color:var(--sub);margin-left:auto;">{{ total_contracts }} 个合约</span>
<div style="display:flex;align-items:center;gap:12px;margin-bottom:14px;">
<span style="font-size:0.78rem;color:var(--sub);">共 {{ total_contracts }} 个合约</span>
</div>
<div id="sync-progress" style="display:none;margin-bottom:18px;padding:12px 16px;background:var(--surface);border:1px solid var(--border);border-radius:8px;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px;">
<span id="sync-progress-label" style="font-size:0.82rem;color:var(--fg);">正在同步...</span>
<span id="sync-progress-pct" style="font-size:0.8rem;color:var(--sub);margin-left:auto;"></span>
</div>
<div style="width:100%;height:5px;background:var(--border);border-radius:3px;overflow:hidden;">
<div id="sync-progress-bar" style="width:0%;height:100%;background:var(--accent);border-radius:3px;transition:width .3s;"></div>
</div>
</div>
{% if products %}
@@ -57,7 +54,7 @@
<span style="font-size:0.78rem;color:var(--sub);">{{ p.exchange }}</span>
<span style="font-size:0.78rem;color:var(--sub);margin-left:8px;">{{ p.contracts|length }} 合约</span>
<form method="post" action="/admin/sync/product/{{ p.id }}" style="display:inline;margin-left:auto;" onclick="event.stopPropagation()">
<button type="submit" class="btn" style="font-size:0.78rem;padding:4px 14px;background:var(--accent);color:#fff;border-radius:5px;">同步该品种</button>
<button type="submit" class="btn" style="font-size:0.78rem;padding:4px 14px;background:var(--accent);color:#fff;border-radius:5px;">同步行情</button>
</form>
</div>
<div class="product-body" style="display:none;">
@@ -72,9 +69,8 @@
</td>
<td style="text-align:center;padding:6px 14px;">
<a href="/contracts/{{ c.code }}" style="color:var(--accent);text-decoration:none;font-size:0.82rem;">查看</a>
<form method="post" action="/admin/sync/{{ c.code }}" style="display:inline;margin-left:8px;">
<button style="background:none;border:none;color:var(--accent);cursor:pointer;font-size:0.82rem;">同步</button>
</form>
<button style="background:none;border:none;color:var(--accent);cursor:pointer;font-size:0.82rem;margin-left:4px;" onclick="syncBars('{{ c.code }}')">↻ 行情</button>
<button style="background:none;border:none;color:var(--success);cursor:pointer;font-size:0.82rem;" onclick="syncPositions('{{ c.code }}')">持仓</button>
</td>
</tr>
{% endfor %}
@@ -196,6 +192,60 @@
</div>
<script>
var syncTimer = null;
var progressShownAt = 0;
function showProgress() {
progressShownAt = Date.now();
document.getElementById('sync-progress').style.display = 'block';
document.getElementById('sync-progress-bar').style.width = '0%';
document.getElementById('sync-progress-pct').textContent = '';
document.getElementById('sync-progress-label').textContent = '正在同步...';
}
function hideProgress() {
clearInterval(syncTimer);
syncTimer = null;
document.getElementById('sync-progress').style.display = 'none';
}
function pollProgress() {
fetch('/admin/sync-status')
.then(function(r) { return r.json(); })
.then(function(data) {
var pct = data.total > 0 ? Math.round(data.done / data.total * 100) : 0;
document.getElementById('sync-progress-bar').style.width = pct + '%';
document.getElementById('sync-progress-pct').textContent = pct + '%';
document.getElementById('sync-progress-label').textContent = data.label;
if (data.finished) {
var elapsed = Date.now() - progressShownAt;
var delay = elapsed < 1000 ? 1000 - elapsed : 0;
setTimeout(function() {
clearInterval(syncTimer);
syncTimer = null;
document.getElementById('sync-progress-bar').style.width = '100%';
document.getElementById('sync-progress-label').textContent = data.label + ' ✓';
document.getElementById('sync-progress-pct').textContent = '';
}, delay);
}
});
}
function startSync(url) {
if (syncTimer) { clearInterval(syncTimer); syncTimer = null; }
showProgress();
fetch(url, { method: 'POST' })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.ok) { hideProgress(); return; }
syncTimer = setInterval(pollProgress, 500);
});
}
function syncBars(code) { startSync('/admin/sync/' + code + '/bg'); }
function syncPositions(code) { startSync('/admin/sync-positions/' + code + '/bg'); }
function switchTab(name) {
document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); });
document.querySelectorAll('.tab-panel').forEach(function(p) { p.classList.remove('active'); });
BIN
View File
Binary file not shown.