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__/ __pycache__/
*.pyc *.pyc
+133
View File
@@ -1,9 +1,142 @@
"""Data collector — fetch OHLCV from akshare and upsert into daily_bars.""" """Data collector — fetch OHLCV from akshare and upsert into daily_bars."""
import threading
from datetime import date from datetime import date
from app.database import SessionLocal from app.database import SessionLocal
from app.models import DailyBar, Contract from app.models import DailyBar, Contract
from app.engine.lock_strategy import compute_amp_5d 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]: def fetch_contract_bars(contract_code: str, start_date: str | None = None) -> list[dict]:
"""Fetch daily OHLCV for a single contract from akshare. """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 import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.models import Product, Contract, DailyBar, PositionRanking 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"]) 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) 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}") @router.post("/sync/{contract_code}")
def sync_single(contract_code: str): def sync_single(contract_code: str):
count = sync_one_contract(contract_code.upper()) count = sync_one_contract(contract_code.upper())
return RedirectResponse(f"/admin/?tab=sync&synced={count}", status_code=303) return RedirectResponse(f"/admin/?tab=sync&synced={count}", status_code=303)
@router.post("/sync-positions") @router.post("/sync/{contract_code}/bg")
def sync_positions(request: Request): def sync_single_bg(contract_code: str):
results = sync_position_rankings() ok = sync_contract_bars_bg(contract_code.upper())
total = sum(results.values()) return JSONResponse({"ok": ok})
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-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}") @router.post("/sync/product/{product_id}")
+69 -19
View File
@@ -29,21 +29,18 @@
{# ═══════════════════ Tab: 数据同步 ═══════════════════ #} {# ═══════════════════ Tab: 数据同步 ═══════════════════ #}
<div class="tab-panel{% if tab == 'sync' %} active{% endif %}" id="tab-sync"> <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;"> <div style="display:flex;align-items:center;gap:12px;margin-bottom:14px;">
<span style="font-size:0.85rem;color:var(--sub);">全部合约</span> <span style="font-size:0.78rem;color:var(--sub);">共 {{ total_contracts }} 个合约</span>
<form method="post" action="/admin/sync" style="display:inline;"> </div>
<button type="submit" class="btn btn-primary" style="font-size:0.82rem;padding:6px 16px;">同步行情</button>
</form> <div id="sync-progress" style="display:none;margin-bottom:18px;padding:12px 16px;background:var(--surface);border:1px solid var(--border);border-radius:8px;">
<form method="post" action="/admin/sync-positions" style="display:inline;"> <div style="display:flex;align-items:center;gap:10px;margin-bottom:6px;">
<button type="submit" class="btn btn-primary" style="font-size:0.82rem;padding:6px 16px;background:var(--success);">同步持仓</button> <span id="sync-progress-label" style="font-size:0.82rem;color:var(--fg);">正在同步...</span>
</form> <span id="sync-progress-pct" style="font-size:0.8rem;color:var(--sub);margin-left:auto;"></span>
{% if request.query_params.get('synced') %} </div>
<span style="font-size:0.82rem;color:var(--success-fg);">✓ 行情 {{ request.query_params.synced }} 条</span> <div style="width:100%;height:5px;background:var(--border);border-radius:3px;overflow:hidden;">
{% endif %} <div id="sync-progress-bar" style="width:0%;height:100%;background:var(--accent);border-radius:3px;transition:width .3s;"></div>
{% if request.query_params.get('pos_synced') %} </div>
<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> </div>
{% if products %} {% 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);">{{ p.exchange }}</span>
<span style="font-size:0.78rem;color:var(--sub);margin-left:8px;">{{ p.contracts|length }} 合约</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()"> <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> </form>
</div> </div>
<div class="product-body" style="display:none;"> <div class="product-body" style="display:none;">
@@ -72,9 +69,8 @@
</td> </td>
<td style="text-align:center;padding:6px 14px;"> <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> <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;margin-left:4px;" onclick="syncBars('{{ c.code }}')">↻ 行情</button>
<button style="background:none;border:none;color:var(--accent);cursor:pointer;font-size:0.82rem;">同步</button> <button style="background:none;border:none;color:var(--success);cursor:pointer;font-size:0.82rem;" onclick="syncPositions('{{ c.code }}')">持仓</button>
</form>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -196,6 +192,60 @@
</div> </div>
<script> <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) { function switchTab(name) {
document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); }); document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); });
document.querySelectorAll('.tab-panel').forEach(function(p) { p.classList.remove('active'); }); document.querySelectorAll('.tab-panel').forEach(function(p) { p.classList.remove('active'); });
BIN
View File
Binary file not shown.