增加数据同步功能,支持从 akshare 拉取合约日线并自动计算振幅
This commit is contained in:
@@ -0,0 +1,135 @@
|
|||||||
|
"""Data collector — fetch OHLCV from akshare and upsert into daily_bars."""
|
||||||
|
from datetime import date
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from app.models import DailyBar, Contract
|
||||||
|
from app.engine.lock_strategy import compute_amp_5d
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_contract_bars(contract_code: str, start_date: str | None = None) -> list[dict]:
|
||||||
|
"""Fetch daily OHLCV for a single contract from akshare.
|
||||||
|
|
||||||
|
Returns list of {date, open, close, high, low} dicts.
|
||||||
|
"""
|
||||||
|
import akshare as ak
|
||||||
|
|
||||||
|
try:
|
||||||
|
df = ak.futures_zh_daily_sina(symbol=contract_code.upper())
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[collector] akshare error for {contract_code}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
if df is None or df.empty:
|
||||||
|
print(f"[collector] No data returned for {contract_code}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
bars = []
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
try:
|
||||||
|
val = row["date"]
|
||||||
|
if hasattr(val, "strftime"):
|
||||||
|
d = val.date() if hasattr(val, "date") else val
|
||||||
|
else:
|
||||||
|
d = date.fromisoformat(str(val)[:10])
|
||||||
|
|
||||||
|
if start_date and d <= date.fromisoformat(start_date):
|
||||||
|
continue
|
||||||
|
|
||||||
|
bars.append({
|
||||||
|
"date": d,
|
||||||
|
"open": float(row["open"]),
|
||||||
|
"close": float(row["close"]),
|
||||||
|
"high": float(row["high"]),
|
||||||
|
"low": float(row["low"]),
|
||||||
|
})
|
||||||
|
except (KeyError, ValueError, TypeError) as e:
|
||||||
|
print(f"[collector] skip row: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
return bars
|
||||||
|
|
||||||
|
|
||||||
|
def sync_active_contracts() -> dict:
|
||||||
|
"""Sync all active contracts. Returns {contract_code: new_bars_count}."""
|
||||||
|
db = SessionLocal()
|
||||||
|
results = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
active_contracts = (
|
||||||
|
db.query(Contract).filter(Contract.is_active == True).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
for c in active_contracts:
|
||||||
|
count = _sync_one(db, c.code)
|
||||||
|
results[c.code] = count
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def sync_one_contract(contract_code: str) -> int:
|
||||||
|
"""Sync a single contract. Returns number of new bars inserted."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
count = _sync_one(db, contract_code.upper())
|
||||||
|
db.commit()
|
||||||
|
return count
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_one(db, contract_code: str) -> int:
|
||||||
|
"""Internal: sync one contract using existing session."""
|
||||||
|
latest = (
|
||||||
|
db.query(DailyBar.date)
|
||||||
|
.filter(DailyBar.contract == contract_code)
|
||||||
|
.order_by(DailyBar.date.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
start_date = latest[0].isoformat() if latest else None
|
||||||
|
|
||||||
|
bars = fetch_contract_bars(contract_code, start_date)
|
||||||
|
if not bars:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
inserted = 0
|
||||||
|
for bar in bars:
|
||||||
|
existing = (
|
||||||
|
db.query(DailyBar)
|
||||||
|
.filter(
|
||||||
|
DailyBar.contract == contract_code,
|
||||||
|
DailyBar.date == bar["date"],
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not existing:
|
||||||
|
db.add(DailyBar(
|
||||||
|
contract=contract_code,
|
||||||
|
date=bar["date"],
|
||||||
|
open=bar["open"],
|
||||||
|
close=bar["close"],
|
||||||
|
high=bar["high"],
|
||||||
|
low=bar["low"],
|
||||||
|
))
|
||||||
|
inserted += 1
|
||||||
|
|
||||||
|
if inserted > 0:
|
||||||
|
db.flush()
|
||||||
|
_recompute_amp(db, contract_code)
|
||||||
|
|
||||||
|
return inserted
|
||||||
|
|
||||||
|
|
||||||
|
def _recompute_amp(db, contract_code: str):
|
||||||
|
"""Recompute amp_5d for all bars of a contract."""
|
||||||
|
bars = (
|
||||||
|
db.query(DailyBar)
|
||||||
|
.filter(DailyBar.contract == contract_code)
|
||||||
|
.order_by(DailyBar.date)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for i, bar in enumerate(bars):
|
||||||
|
if i >= 5:
|
||||||
|
bar.amp_5d = compute_amp_5d([b.diff for b in bars[i - 5 : i]])
|
||||||
@@ -3,6 +3,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
|||||||
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
|
from app.models import Product, Contract
|
||||||
|
from app.collector import sync_active_contracts, sync_one_contract
|
||||||
|
|
||||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||||
|
|
||||||
@@ -105,3 +106,17 @@ def delete_product(product_id: int, db: Session = Depends(get_db)):
|
|||||||
db.delete(p)
|
db.delete(p)
|
||||||
db.commit()
|
db.commit()
|
||||||
return RedirectResponse("/admin/", status_code=303)
|
return RedirectResponse("/admin/", 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("/admin/?synced=" + str(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/?synced={count}", status_code=303)
|
||||||
|
|||||||
@@ -5,6 +5,17 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
|
|
||||||
|
{# ── Sync Bar ── #}
|
||||||
|
<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>
|
||||||
|
{% if request.query_params.get('synced') %}
|
||||||
|
<span style="font-size:0.82rem;color:var(--success-fg);">✓ 已同步 {{ request.query_params.synced }} 条</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
{# ── New Product ── #}
|
{# ── New Product ── #}
|
||||||
<div class="section-title">新建品种</div>
|
<div class="section-title">新建品种</div>
|
||||||
<div class="form-card" style="margin-bottom:24px;">
|
<div class="form-card" style="margin-bottom:24px;">
|
||||||
@@ -65,7 +76,12 @@
|
|||||||
<span class="badge badge-down">停用</span>
|
<span class="badge badge-down">停用</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td><a href="/contracts/{{ c.code }}" style="color:var(--accent);text-decoration:none;">查看 →</a></td>
|
<td>
|
||||||
|
<a href="/contracts/{{ c.code }}" style="color:var(--accent);text-decoration:none;">查看</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.78rem;">↻ 同步</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<form method="post" action="/admin/contract/{{ c.id }}/toggle" style="display:inline;">
|
<form method="post" action="/admin/contract/{{ c.id }}/toggle" style="display:inline;">
|
||||||
<button style="background:none;border:none;color:var(--sub);cursor:pointer;font-size:0.82rem;">
|
<button style="background:none;border:none;color:var(--sub);cursor:pointer;font-size:0.82rem;">
|
||||||
|
|||||||
Reference in New Issue
Block a user