搭建期货量化管理后台 MVP,支持行情查看、博弈分析、持仓录入、品种合约管理

This commit is contained in:
2026-07-24 21:38:40 +08:00
parent d06ed06f01
commit 1ff2aa17e8
22 changed files with 1486 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
data/
__pycache__/
*.pyc
+13
View File
@@ -0,0 +1,13 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
RUN mkdir -p /app/data
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+23
View File
@@ -0,0 +1,23 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
from pathlib import Path
DATA_DIR = Path(__file__).parent.parent / "data"
DATA_DIR.mkdir(exist_ok=True)
DATABASE_URL = f"sqlite:///{DATA_DIR / 'ft.db'}"
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(bind=engine)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
View File
+26
View File
@@ -0,0 +1,26 @@
"""博弈分析计算引擎"""
def net_position(long_pos: int, short_pos: int) -> int:
"""净持仓 = 多单 - 空单。正=净多, 负=净空"""
return long_pos - short_pos
def net_pnl(net_pos: int, avg_cost: float, current_price: float) -> float:
"""净盈亏 = 净持仓 × (现价 - 成本均价) × 20"""
return net_pos * (current_price - avg_cost) * 20
def cost_delta(old_cost: float, new_cost: float) -> float:
"""成本变化 = 新均价 - 旧均价"""
return round(new_cost - old_cost, 2)
def format_pnl(pnl_yuan: float) -> str:
"""格式化盈亏为亿/万"""
yi = abs(pnl_yuan) / 1e8
if yi >= 0.01:
sign = "+" if pnl_yuan >= 0 else "-"
return f"{sign}{yi:.2f}亿"
wan = abs(pnl_yuan) / 1e4
return f"{'+' if pnl_yuan >= 0 else '-'}{wan:.1f}"
+33
View File
@@ -0,0 +1,33 @@
"""振幅锁仓策略计算引擎"""
def compute_amp_5d(diffs: list[float]) -> float:
"""近5日均振幅 = round(mean of 5 diffs)"""
if len(diffs) < 5:
return 0.0
return round(sum(diffs[-5:]) / 5)
def check_stop_profit(
active_profits: list[float],
locked_losses: list[float],
daily_hands: int,
amp_threshold: float,
) -> bool:
"""止盈条件: total_floating_profit >= N * A"""
total = sum(active_profits) + sum(locked_losses)
return total >= daily_hands * amp_threshold
def check_lock(
open_price: float,
current_price: float,
lock_threshold: float,
) -> bool:
"""锁仓条件: 空单亏损 >= 开仓时的振幅阈值"""
return (current_price - open_price) >= lock_threshold
def should_meltdown(lock_count: int) -> bool:
"""3锁熔断"""
return lock_count >= 3
+36
View File
@@ -0,0 +1,36 @@
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from jinja2 import Environment, FileSystemLoader
from app.database import engine, Base
from app.seed import seed
from app.routers import contracts, analysis, data_input, admin
TEMPLATES_DIR = Path(__file__).parent / "templates"
def setup_jinja(app: FastAPI):
env = Environment(loader=FileSystemLoader(str(TEMPLATES_DIR)))
app.state.templates = env
@asynccontextmanager
async def lifespan(app: FastAPI):
setup_jinja(app)
Base.metadata.create_all(bind=engine)
seed()
yield
app = FastAPI(title="期货量化系统", lifespan=lifespan)
app.include_router(contracts.router)
app.include_router(analysis.router)
app.include_router(data_input.router)
app.include_router(admin.router)
@app.get("/")
def root():
from fastapi.responses import RedirectResponse
return RedirectResponse("/contracts")
+62
View File
@@ -0,0 +1,62 @@
from datetime import date
from sqlalchemy import String, Integer, Float, Date, ForeignKey, Boolean, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Product(Base):
__tablename__ = "products"
id: Mapped[int] = mapped_column(primary_key=True)
code: Mapped[str] = mapped_column(String(10), unique=True, index=True)
name: Mapped[str] = mapped_column(String(20))
exchange: Mapped[str] = mapped_column(String(10), default="CZCE")
contracts: Mapped[list["Contract"]] = relationship(
back_populates="product", cascade="all, delete-orphan"
)
class Contract(Base):
__tablename__ = "contracts"
__table_args__ = (UniqueConstraint("code"),)
id: Mapped[int] = mapped_column(primary_key=True)
product_id: Mapped[int] = mapped_column(ForeignKey("products.id"), index=True)
code: Mapped[str] = mapped_column(String(10), unique=True, index=True)
name: Mapped[str] = mapped_column(String(30))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
product: Mapped["Product"] = relationship(back_populates="contracts")
class DailyBar(Base):
__tablename__ = "daily_bars"
__table_args__ = (UniqueConstraint("contract", "date"),)
id: Mapped[int] = mapped_column(primary_key=True)
contract: Mapped[str] = mapped_column(String(10), index=True)
date: Mapped[date] = mapped_column(Date, index=True)
open: Mapped[float] = mapped_column(Float)
close: Mapped[float] = mapped_column(Float)
high: Mapped[float] = mapped_column(Float)
low: Mapped[float] = mapped_column(Float)
amp_5d: Mapped[float | None] = mapped_column(Float, nullable=True)
@property
def diff(self) -> float:
"""最高-最低价差"""
return self.high - self.low
class PositionSnapshot(Base):
__tablename__ = "position_snapshots"
__table_args__ = (UniqueConstraint("institution", "direction", "date"),)
id: Mapped[int] = mapped_column(primary_key=True)
institution: Mapped[str] = mapped_column(String(20), index=True)
direction: Mapped[str] = mapped_column(String(10)) # "long" or "short"
date: Mapped[date] = mapped_column(Date, index=True)
position: Mapped[int] = mapped_column(Integer)
delta: Mapped[int] = mapped_column(Integer, default=0)
avg_cost: Mapped[float] = mapped_column(Float)
View File
+107
View File
@@ -0,0 +1,107 @@
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import Product, Contract
router = APIRouter(prefix="/admin", tags=["admin"])
EXCHANGES = ["CZCE", "DCE", "SHFE", "CFFEX", "INE"]
@router.get("/", response_class=HTMLResponse)
def admin_page(request: Request, db: Session = Depends(get_db)):
products = db.query(Product).order_by(Product.code).all()
product_data = []
for p in products:
contracts = (
db.query(Contract)
.filter(Contract.product_id == p.id)
.order_by(Contract.code)
.all()
)
product_data.append({
"id": p.id,
"code": p.code,
"name": p.name,
"exchange": p.exchange,
"contracts": [
{"id": c.id, "code": c.code, "name": c.name, "is_active": c.is_active}
for c in contracts
],
})
template = request.app.state.templates.get_template("admin.html")
return HTMLResponse(
template.render(
request=request,
active_nav="admin",
products=product_data,
exchanges=EXCHANGES,
)
)
@router.post("/product")
def create_product(
request: Request,
code: str = Form(...),
name: str = Form(...),
exchange: str = Form(...),
db: Session = Depends(get_db),
):
existing = db.query(Product).filter(Product.code == code.upper()).first()
if not existing:
p = Product(code=code.upper(), name=name, exchange=exchange)
db.add(p)
db.commit()
return RedirectResponse("/admin/", status_code=303)
@router.post("/contract")
def create_contract(
request: Request,
product_id: int = Form(...),
code: str = Form(...),
name: str = Form(...),
db: Session = Depends(get_db),
):
existing = db.query(Contract).filter(Contract.code == code.upper()).first()
if not existing:
c = Contract(
product_id=product_id,
code=code.upper(),
name=name,
is_active=True,
)
db.add(c)
db.commit()
return RedirectResponse("/admin/", status_code=303)
@router.post("/contract/{contract_id}/toggle")
def toggle_contract(contract_id: int, db: Session = Depends(get_db)):
c = db.query(Contract).filter(Contract.id == contract_id).first()
if c:
c.is_active = not c.is_active
db.commit()
return RedirectResponse("/admin/", status_code=303)
@router.post("/contract/{contract_id}/delete")
def delete_contract(contract_id: int, db: Session = Depends(get_db)):
c = db.query(Contract).filter(Contract.id == contract_id).first()
if c:
db.delete(c)
db.commit()
return RedirectResponse("/admin/", status_code=303)
@router.post("/product/{product_id}/delete")
def delete_product(product_id: int, db: Session = Depends(get_db)):
p = db.query(Product).filter(Product.id == product_id).first()
if p:
db.delete(p)
db.commit()
return RedirectResponse("/admin/", status_code=303)
+102
View File
@@ -0,0 +1,102 @@
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import PositionSnapshot
from app.engine.game_theory import net_position, net_pnl, format_pnl
router = APIRouter(prefix="/analysis", tags=["analysis"])
INSTITUTIONS = ["中信期货", "高盛期货", "国泰君安期货", "华泰期货", "东证期货", "银河期货"]
@router.get("/", response_class=HTMLResponse)
def analysis_page(request: Request, db: Session = Depends(get_db)):
latest_snap = (
db.query(PositionSnapshot.date)
.order_by(PositionSnapshot.date.desc())
.first()
)
if not latest_snap:
template = request.app.state.templates.get_template("analysis.html")
return HTMLResponse(
template.render(request=request, active_nav="analysis", rows=[], latest_date=None)
)
latest_date = latest_snap[0]
current_price = 913 # TODO: fetch from daily_bars or akshare
rows = []
totals = {"long_pos": 0, "short_pos": 0, "net_pos": 0, "pnl": 0.0}
total_net_short = 0
pnl_sum = 0.0
for inst in INSTITUTIONS:
long = (
db.query(PositionSnapshot)
.filter(
PositionSnapshot.institution == inst,
PositionSnapshot.direction == "long",
PositionSnapshot.date == latest_date,
)
.first()
)
short = (
db.query(PositionSnapshot)
.filter(
PositionSnapshot.institution == inst,
PositionSnapshot.direction == "short",
PositionSnapshot.date == latest_date,
)
.first()
)
long_pos = long.position if long else 0
short_pos = short.position if short else 0
long_cost = long.avg_cost if long else 0
short_cost = short.avg_cost if short else 0
np = net_position(long_pos, short_pos)
pnl = 0.0
if np < 0:
pnl = net_pnl(np, short_cost, current_price)
elif np > 0:
pnl = net_pnl(np, long_cost, current_price)
totals["long_pos"] += long_pos
totals["short_pos"] += short_pos
totals["net_pos"] += np
pnl_sum += pnl
if np < 0:
total_net_short += abs(np)
rows.append({
"institution": inst,
"long_pos": f"{long_pos / 10000:.1f}" if long_pos else "",
"short_pos": f"{short_pos / 10000:.1f}" if short_pos else "",
"long_cost": f"{long_cost:.2f}" if long_cost else "",
"short_cost": f"{short_cost:.2f}" if short_cost else "",
"net_pos": f"{'' if np > 0 else ''} {abs(np) / 10000:.1f}",
"pnl": format_pnl(pnl),
"pnl_raw": pnl,
})
template = request.app.state.templates.get_template("analysis.html")
return HTMLResponse(
template.render(
request=request,
active_nav="analysis",
rows=rows,
totals={
"long_pos": f"{totals['long_pos'] / 10000:.1f}",
"short_pos": f"{totals['short_pos'] / 10000:.1f}",
"net_pos": f"{'' if totals['net_pos'] > 0 else ''} {abs(totals['net_pos']) / 10000:.1f}",
"pnl": format_pnl(pnl_sum),
},
latest_date=latest_date.strftime("%Y-%m-%d"),
current_price=current_price,
total_net_short=f"{total_net_short / 10000:.1f}",
total_pnl=format_pnl(pnl_sum),
)
)
+93
View File
@@ -0,0 +1,93 @@
from datetime import date
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import DailyBar, Contract
router = APIRouter(prefix="/contracts", tags=["contracts"])
WEEKDAY_ZH = {0: "周一", 1: "周二", 2: "周三", 3: "周四", 4: "周五", 5: "周六", 6: "周日"}
def get_active_contracts(db: Session) -> list[str]:
contracts = (
db.query(Contract.code)
.filter(Contract.is_active == True)
.order_by(Contract.code)
.all()
)
return [c[0] for c in contracts]
@router.get("/", response_class=HTMLResponse)
def contract_index(request: Request, db: Session = Depends(get_db)):
active_contracts = get_active_contracts(db)
latest = (
db.query(DailyBar)
.filter(DailyBar.contract.in_(active_contracts))
.order_by(DailyBar.date.desc())
.all()
)
contract_bars = {}
for bar in latest:
if bar.contract not in contract_bars:
contract_bars[bar.contract] = bar
total_bars = db.query(DailyBar).count()
latest_bar = (
db.query(DailyBar).order_by(DailyBar.date.desc()).first()
)
template = request.app.state.templates.get_template("index.html")
return HTMLResponse(
template.render(
request=request,
active_nav="contracts",
contracts=active_contracts,
contract_bars=contract_bars,
total_bars=total_bars,
latest_date=latest_bar.date.strftime("%Y-%m-%d") if latest_bar else "",
)
)
@router.get("/{contract}", response_class=HTMLResponse)
def contract_detail(request: Request, contract: str, db: Session = Depends(get_db)):
active_contracts = get_active_contracts(db)
bars = (
db.query(DailyBar)
.filter(DailyBar.contract == contract.upper())
.order_by(DailyBar.date)
.all()
)
rows = []
for bar in bars:
rows.append({
"date": bar.date.strftime("%Y/%-m/%-d"),
"weekday": WEEKDAY_ZH.get(bar.date.weekday(), ""),
"open": int(bar.open) if bar.open else "-",
"close": int(bar.close) if bar.close else "-",
"high": int(bar.high) if bar.high else "-",
"low": int(bar.low) if bar.low else "-",
"diff": int(bar.diff) if bar.diff else 0,
"amp_5d": int(bar.amp_5d) if bar.amp_5d is not None else None,
"has_amp": bar.amp_5d is not None,
})
latest = bars[-1] if bars else None
template = request.app.state.templates.get_template("contract.html")
return HTMLResponse(
template.render(
request=request,
active_nav="contracts",
contract=contract.upper(),
contracts=active_contracts,
rows=rows,
latest=latest,
row_count=len(rows),
)
)
+71
View File
@@ -0,0 +1,71 @@
from datetime import date
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import PositionSnapshot
router = APIRouter(prefix="/input", tags=["input"])
INSTITUTIONS = ["中信期货", "高盛期货", "国泰君安期货", "华泰期货", "东证期货", "银河期货"]
@router.get("/", response_class=HTMLResponse)
def input_page(request: Request):
template = request.app.state.templates.get_template("input.html")
return HTMLResponse(
template.render(request=request, active_nav="input", institutions=INSTITUTIONS)
)
@router.post("/")
def submit_position(
request: Request,
institution: str = Form(...),
direction: str = Form(...),
date_str: str = Form(...),
position: int = Form(...),
avg_cost: float = Form(...),
db: Session = Depends(get_db),
):
d = date.fromisoformat(date_str)
prev = (
db.query(PositionSnapshot)
.filter(
PositionSnapshot.institution == institution,
PositionSnapshot.direction == direction,
PositionSnapshot.date < d,
)
.order_by(PositionSnapshot.date.desc())
.first()
)
delta = position - prev.position if prev else 0
existing = (
db.query(PositionSnapshot)
.filter(
PositionSnapshot.institution == institution,
PositionSnapshot.direction == direction,
PositionSnapshot.date == d,
)
.first()
)
if existing:
existing.position = position
existing.delta = delta
existing.avg_cost = avg_cost
else:
snap = PositionSnapshot(
institution=institution,
direction=direction,
date=d,
position=position,
delta=delta,
avg_cost=avg_cost,
)
db.add(snap)
db.commit()
return RedirectResponse("/analysis", status_code=303)
+289
View File
@@ -0,0 +1,289 @@
"""Seed database from existing data files. Run once manually or on first start."""
from datetime import date
from app.database import engine, Base, SessionLocal
from app.models import DailyBar, PositionSnapshot, Product, Contract
from app.engine.lock_strategy import compute_amp_5d
# --- Seed OHLCV data ---
SEED_CONTRACT = "FG2609"
SEED_BARS: list[dict] = [
{"date": "2026-07-01", "open": 972, "close": 961, "high": 972, "low": 956},
{"date": "2026-07-02", "open": 960, "close": 966, "high": 972, "low": 957},
{"date": "2026-07-03", "open": 965, "close": 973, "high": 979, "low": 962},
{"date": "2026-07-06", "open": 977, "close": 967, "high": 982, "low": 964},
{"date": "2026-07-07", "open": 966, "close": 952, "high": 966, "low": 951},
{"date": "2026-07-08", "open": 954, "close": 962, "high": 966, "low": 951},
{"date": "2026-07-09", "open": 963, "close": 957, "high": 964, "low": 949},
{"date": "2026-07-10", "open": 956, "close": 965, "high": 974, "low": 952},
{"date": "2026-07-13", "open": 965, "close": 952, "high": 967, "low": 950},
{"date": "2026-07-14", "open": 954, "close": 951, "high": 956, "low": 944},
{"date": "2026-07-15", "open": 950, "close": 949, "high": 958, "low": 944},
{"date": "2026-07-16", "open": 949, "close": 929, "high": 952, "low": 927},
{"date": "2026-07-17", "open": 931, "close": 900, "high": 931, "low": 899},
{"date": "2026-07-20", "open": 902, "close": 890, "high": 908, "low": 889},
{"date": "2026-07-21", "open": 891, "close": 904, "high": 910, "low": 891},
{"date": "2026-07-22", "open": 908, "close": 913, "high": 918, "low": 900},
{"date": "2026-07-23", "open": 914, "close": 899, "high": 917, "low": 892},
{"date": "2026-07-24", "open": 900, "close": 908, "high": 912, "low": 891},
]
# --- Position snapshots (from info.txt) ---
POSITION_DATA_SHORT: list[dict] = [
("中信期货", "short", "2026-07-22", 193030, -22032, 995.26),
("中信期货", "short", "2026-07-21", 215062, -1029, 995.26),
("中信期货", "short", "2026-07-20", 216091, -18178, 995.26),
("中信期货", "short", "2026-07-17", 234269, -31, 995.26),
("中信期货", "short", "2026-07-16", 234300, 35258, 995.26),
("中信期货", "short", "2026-07-15", 199042, 5294, 1005.40),
("中信期货", "short", "2026-07-14", 193748, 4117, 1006.94),
("中信期货", "short", "2026-07-13", 189631, 18219, 1008.16),
("中信期货", "short", "2026-07-10", 171412, -17319, 1013.49),
("中信期货", "short", "2026-07-09", 188731, 9860, 1013.49),
("中信期货", "short", "2026-07-08", 178871, -19747, 1016.71),
("中信期货", "short", "2026-07-07", 198618, 25262, 1016.71),
("中信期货", "short", "2026-07-06", 173356, 2998, 1025.41),
("中信期货", "short", "2026-07-03", 170358, -11691, 1026.30),
("中信期货", "short", "2026-07-02", 182049, 1284, 1026.30),
("中信期货", "long", "2026-07-22", 63971, -10850, 988.16),
("中信期货", "long", "2026-07-21", 74821, 2587, 988.16),
("中信期货", "long", "2026-07-20", 72234, -84, 991.28),
("中信期货", "long", "2026-07-17", 72318, 6928, 991.28),
("中信期货", "long", "2026-07-16", 65390, 6950, 999.15),
("中信期货", "long", "2026-07-15", 58440, -1227, 1006.42),
("中信期货", "long", "2026-07-14", 59667, 887, 1006.42),
("中信期货", "long", "2026-07-13", 58780, -4908, 1007.26),
("中信期货", "long", "2026-07-10", 63688, 5795, 1007.26),
("中信期货", "long", "2026-07-09", 57893, -3193, 1011.59),
("中信期货", "long", "2026-07-08", 61086, 2092, 1011.59),
("中信期货", "long", "2026-07-07", 58994, 8116, 1013.45),
("中信期货", "long", "2026-07-06", 50878, -14738, 1022.46),
("中信期货", "long", "2026-07-03", 65616, 9777, 1022.46),
("中信期货", "long", "2026-07-02", 55839, -2024, 1031.30),
("高盛期货", "short", "2026-07-22", 188460, -14927, 997.89),
("高盛期货", "short", "2026-07-21", 203387, -15903, 997.89),
("高盛期货", "short", "2026-07-20", 219290, 1220, 997.89),
("高盛期货", "short", "2026-07-17", 218070, 14452, 998.44),
("高盛期货", "short", "2026-07-16", 203618, 12879, 1004.22),
("高盛期货", "short", "2026-07-15", 190739, -5701, 1008.69),
("高盛期货", "short", "2026-07-14", 196440, 1711, 1008.69),
("高盛期货", "short", "2026-07-13", 194729, 418, 1009.20),
("高盛期货", "short", "2026-07-10", 194311, -5203, 1009.31),
("高盛期货", "short", "2026-07-09", 199514, 7653, 1009.31),
("高盛期货", "short", "2026-07-08", 191861, 1394, 1011.47),
("高盛期货", "short", "2026-07-07", 190467, 12046, 1011.86),
("高盛期货", "short", "2026-07-06", 178421, 578, 1015.56),
("高盛期货", "short", "2026-07-03", 177843, 1049, 1015.69),
("高盛期货", "short", "2026-07-02", 176794, 19121, 1015.95),
("国泰君安期货", "short", "2026-07-22", 168449, -9289, 1018.54),
("国泰君安期货", "short", "2026-07-21", 177738, -10380, 1018.54),
("国泰君安期货", "short", "2026-07-20", 188118, -731, 1018.54),
("国泰君安期货", "short", "2026-07-17", 188849, -12984, 1018.54),
("国泰君安期货", "short", "2026-07-16", 201833, -5144, 1018.54),
("国泰君安期货", "short", "2026-07-15", 206977, 2787, 1018.54),
("国泰君安期货", "short", "2026-07-14", 204190, -2399, 1019.49),
("国泰君安期货", "short", "2026-07-13", 206589, 24344, 1019.49),
("国泰君安期货", "short", "2026-07-10", 182245, -4391, 1027.70),
("国泰君安期货", "short", "2026-07-09", 186636, -6510, 1027.70),
("国泰君安期货", "short", "2026-07-08", 193146, -6957, 1027.70),
("国泰君安期货", "short", "2026-07-07", 200103, 9696, 1027.70),
("国泰君安期货", "short", "2026-07-06", 190407, 1344, 1031.30),
("国泰君安期货", "short", "2026-07-03", 189063, -1588, 1031.70),
("国泰君安期货", "short", "2026-07-02", 190651, 4656, 1031.70),
("国泰君安期货", "long", "2026-07-22", 144325, -6262, 991.07),
("国泰君安期货", "long", "2026-07-21", 150587, 17790, 991.07),
("国泰君安期货", "long", "2026-07-20", 132797, 3578, 1003.13),
("国泰君安期货", "long", "2026-07-17", 129219, 12066, 1005.99),
("国泰君安期货", "long", "2026-07-16", 117153, 8387, 1015.15),
("国泰君安期货", "long", "2026-07-15", 108766, 5534, 1021.10),
("国泰君安期货", "long", "2026-07-14", 103232, 4815, 1024.97),
("国泰君安期货", "long", "2026-07-13", 98417, 4839, 1028.58),
("国泰君安期货", "long", "2026-07-10", 93578, -1293, 1032.23),
("国泰君安期货", "long", "2026-07-09", 94871, -807, 1032.23),
("国泰君安期货", "long", "2026-07-08", 95678, -5169, 1032.23),
("国泰君安期货", "long", "2026-07-07", 100847, 3412, 1032.23),
("国泰君安期货", "long", "2026-07-06", 97435, -4615, 1034.87),
("国泰君安期货", "long", "2026-07-03", 102050, 2169, 1034.87),
("国泰君安期货", "long", "2026-07-02", 99881, 9801, 1036.23),
("华泰期货", "short", "2026-07-22", 80564, -8762, 976.69),
("华泰期货", "short", "2026-07-21", 89326, 9844, 976.69),
("华泰期货", "short", "2026-07-20", 79482, 1751, 986.06),
("华泰期货", "short", "2026-07-17", 77731, -8367, 988.00),
("华泰期货", "short", "2026-07-16", 86098, 11358, 988.00),
("华泰期货", "short", "2026-07-15", 74740, 3028, 995.60),
("华泰期货", "short", "2026-07-14", 71712, -2199, 997.57),
("华泰期货", "short", "2026-07-13", 73911, 10715, 997.57),
("华泰期货", "short", "2026-07-10", 63196, -3512, 1004.28),
("华泰期货", "short", "2026-07-09", 66708, 3827, 1004.28),
("华泰期货", "short", "2026-07-08", 62881, -10077, 1007.27),
("华泰期货", "short", "2026-07-07", 72958, 12276, 1007.27),
("华泰期货", "short", "2026-07-06", 60682, -6933, 1017.45),
("华泰期货", "short", "2026-07-03", 65615, -5963, 1017.45),
("华泰期货", "short", "2026-07-02", 71558, -2128, 1017.45),
("华泰期货", "long", "2026-07-22", 68791, -5130, 1002.62),
("华泰期货", "long", "2026-07-21", 73921, -274, 1002.62),
("华泰期货", "long", "2026-07-20", 74195, 840, 1002.62),
("华泰期货", "long", "2026-07-17", 73355, 7012, 1003.79),
("华泰期货", "long", "2026-07-16", 66343, 4562, 1012.97),
("华泰期货", "long", "2026-07-15", 61781, -1562, 1018.50),
("华泰期货", "long", "2026-07-14", 63343, 2364, 1018.50),
("华泰期货", "long", "2026-07-13", 60979, -1843, 1021.12),
("华泰期货", "long", "2026-07-10", 62822, 1161, 1021.12),
("华泰期货", "long", "2026-07-09", 61661, 162, 1022.19),
("华泰期货", "long", "2026-07-08", 61499, 82, 1022.37),
("华泰期货", "long", "2026-07-07", 61417, 6050, 1022.46),
("华泰期货", "long", "2026-07-06", 55367, -2699, 1029.61),
("华泰期货", "long", "2026-07-03", 58066, -4006, 1029.61),
("华泰期货", "long", "2026-07-02", 62072, -2935, 1029.61),
("东证期货", "short", "2026-07-22", 112614, -18937, 965.67),
("东证期货", "short", "2026-07-21", 131551, -45486, 965.67),
("东证期货", "short", "2026-07-20", 177037, -1629, 965.67),
("东证期货", "short", "2026-07-17", 178666, -4252, 965.67),
("东证期货", "short", "2026-07-16", 182918, 47376, 965.67),
("东证期货", "short", "2026-07-15", 135542, -13505, 975.35),
("东证期货", "short", "2026-07-14", 149047, -1108, 975.35),
("东证期货", "short", "2026-07-13", 150155, 40931, 975.35),
("东证期货", "short", "2026-07-10", 109224, -23556, 981.85),
("东证期货", "short", "2026-07-09", 132780, 25017, 981.85),
("东证期货", "short", "2026-07-08", 107763, -56346, 988.08),
("东证期货", "short", "2026-07-07", 164109, 37956, 988.08),
("东证期货", "short", "2026-07-06", 126153, 20536, 997.43),
("东证期货", "short", "2026-07-03", 105617, -10003, 1001.79),
("东证期货", "short", "2026-07-02", 115620, -16409, 1001.79),
("东证期货", "long", "2026-07-22", 85630, 6685, 965.35),
("东证期货", "long", "2026-07-21", 78945, 8359, 970.20),
("东证期货", "long", "2026-07-20", 70586, 2334, 978.40),
("东证期货", "long", "2026-07-17", 68252, -5918, 981.08),
("东证期货", "long", "2026-07-16", 74170, 10413, 981.08),
("东证期货", "long", "2026-07-15", 63757, 1640, 988.12),
("东证期货", "long", "2026-07-14", 62117, -2136, 989.15),
("东证期货", "long", "2026-07-13", 64253, -14158, 989.15),
("东证期货", "long", "2026-07-10", 78411, 7829, 989.15),
("东证期货", "long", "2026-07-09", 70582, -9231, 991.94),
("东证期货", "long", "2026-07-08", 79813, 11598, 991.94),
("东证期货", "long", "2026-07-07", 68215, 10382, 997.54),
("东证期货", "long", "2026-07-06", 57833, -30335, 1004.82),
("东证期货", "long", "2026-07-03", 88168, 20705, 1004.82),
("东证期货", "long", "2026-07-02", 67463, 3862, 1014.89),
("银河期货", "short", "2026-07-22", 62130, -3771, 1022.69),
("银河期货", "short", "2026-07-21", 65901, -3865, 1022.69),
("银河期货", "short", "2026-07-20", 69766, 1572, 1022.69),
("银河期货", "short", "2026-07-17", 68194, 431, 1025.51),
("银河期货", "short", "2026-07-16", 67763, -936, 1026.20),
("银河期货", "short", "2026-07-15", 68699, 372, 1026.20),
("银河期货", "short", "2026-07-14", 68327, 5179, 1026.62),
("银河期货", "short", "2026-07-13", 63148, -3119, 1032.83),
("银河期货", "short", "2026-07-10", 66267, -431, 1032.83),
("银河期货", "short", "2026-07-09", 66698, 1995, 1032.83),
("银河期货", "short", "2026-07-08", 64703, -5025, 1035.23),
("银河期货", "short", "2026-07-07", 69728, 6103, 1035.23),
("银河期货", "short", "2026-07-06", 63625, -1703, 1042.73),
("银河期货", "short", "2026-07-03", 65328, -3861, 1042.73),
("银河期货", "short", "2026-07-02", 69189, 1875, 1042.73),
("银河期货", "long", "2026-07-22", 57020, -7565, 1016.17),
("银河期货", "long", "2026-07-21", 64585, -4812, 1016.17),
("银河期货", "long", "2026-07-20", 69397, -2877, 1016.17),
("银河期货", "long", "2026-07-17", 72274, -4410, 1016.17),
("银河期货", "long", "2026-07-16", 76684, 4888, 1016.17),
("银河期货", "long", "2026-07-15", 71796, -3235, 1021.49),
("银河期货", "long", "2026-07-14", 75031, -456, 1021.49),
("银河期货", "long", "2026-07-13", 75487, 4361, 1021.49),
("银河期货", "long", "2026-07-10", 71126, -6491, 1025.39),
("银河期货", "long", "2026-07-09", 77617, 376, 1025.39),
("银河期货", "long", "2026-07-08", 77241, -3621, 1025.73),
("银河期货", "long", "2026-07-07", 80862, 10307, 1025.73),
("银河期货", "long", "2026-07-06", 70555, 2439, 1035.77),
("银河期货", "long", "2026-07-03", 68116, -4101, 1037.95),
("银河期货", "long", "2026-07-02", 72217, 490, 1037.95),
]
def seed():
Base.metadata.create_all(bind=engine)
db = SessionLocal()
try:
# --- Seed products and contracts ---
if not db.query(Product).first():
fg = Product(code="FG", name="玻璃", exchange="CZCE")
db.add(fg)
db.flush()
db.add_all([
Contract(product_id=fg.id, code="FG2609", name="玻璃2609", is_active=True),
Contract(product_id=fg.id, code="FG2610", name="玻璃2610", is_active=True),
Contract(product_id=fg.id, code="FG2611", name="玻璃2611", is_active=True),
Contract(product_id=fg.id, code="FG2701", name="玻璃2701", is_active=True),
])
# --- Seed daily bars ---
existing_dates = {
(r.contract, r.date)
for r in db.query(DailyBar.contract, DailyBar.date).all()
}
bars_to_insert = []
for bar in SEED_BARS:
d = date.fromisoformat(bar["date"])
if (SEED_CONTRACT, d) not in existing_dates:
bars_to_insert.append(
DailyBar(
contract=SEED_CONTRACT,
date=d,
open=bar["open"],
close=bar["close"],
high=bar["high"],
low=bar["low"],
)
)
if bars_to_insert:
db.add_all(bars_to_insert)
db.flush()
# Compute amp_5d
seed_bars = (
db.query(DailyBar)
.filter(DailyBar.contract == SEED_CONTRACT)
.order_by(DailyBar.date)
.all()
)
for i, bar in enumerate(seed_bars):
if i >= 5:
bar.amp_5d = compute_amp_5d([b.diff for b in seed_bars[i - 5 : i]])
# --- Seed position snapshots ---
existing_pos = {
(r.institution, r.direction, r.date)
for r in db.query(
PositionSnapshot.institution,
PositionSnapshot.direction,
PositionSnapshot.date,
).all()
}
snaps = []
for inst, direction, date_str, pos, delta, cost in POSITION_DATA_SHORT:
d = date.fromisoformat(date_str)
if (inst, direction, d) not in existing_pos:
snaps.append(
PositionSnapshot(
institution=inst,
direction=direction,
date=d,
position=pos,
delta=delta,
avg_cost=cost,
)
)
if snaps:
db.add_all(snaps)
db.commit()
print(f"Seeded {len(bars_to_insert)} bars + {len(snaps)} position snapshots")
finally:
db.close()
if __name__ == "__main__":
seed()
+93
View File
@@ -0,0 +1,93 @@
{% extends "base.html" %}
{% block title %}系统管理{% endblock %}
{% block heading %}系统管理{% endblock %}
{% block breadcrumb %}品种与合约{% endblock %}
{% block content %}
{# ── New Product ── #}
<div class="section-title">新建品种</div>
<div class="form-card" style="margin-bottom:24px;">
<form method="post" action="/admin/product" style="display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;">
<div class="form-group" style="margin-bottom:0;flex:1;min-width:120px;">
<label>品种代码</label>
<input type="text" name="code" required placeholder="FG" maxlength="6" style="text-transform:uppercase;">
</div>
<div class="form-group" style="margin-bottom:0;flex:1;min-width:120px;">
<label>品种名称</label>
<input type="text" name="name" required placeholder="玻璃">
</div>
<div class="form-group" style="margin-bottom:0;flex:1;min-width:120px;">
<label>交易所</label>
<select name="exchange" required>
{% for ex in exchanges %}
<option value="{{ ex }}">{{ ex }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn btn-primary">新建</button>
</form>
</div>
{# ── Product List ── #}
{% for p in products %}
<div class="section-title">{{ p.code }} · {{ p.name }} <span style="font-weight:400;color:var(--sub);font-size:0.78rem;">{{ p.exchange }}</span></div>
{# ── New Contract ── #}
<div class="form-card" style="margin-bottom:12px;">
<form method="post" action="/admin/contract" style="display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;">
<input type="hidden" name="product_id" value="{{ p.id }}">
<div class="form-group" style="margin-bottom:0;flex:1;min-width:140px;">
<label>合约代码</label>
<input type="text" name="code" required placeholder="{{ p.code }}2609" maxlength="10" style="text-transform:uppercase;">
</div>
<div class="form-group" style="margin-bottom:0;flex:2;min-width:160px;">
<label>合约名称</label>
<input type="text" name="name" required placeholder="{{ p.name }}2609">
</div>
<button type="submit" class="btn btn-primary">添加合约</button>
</form>
</div>
{# ── Contract List ── #}
{% if p.contracts %}
<div class="table-wrap" style="margin-bottom:20px;">
<table>
<tr><th>合约代码</th><th>名称</th><th>状态</th><th>行情</th><th>操作</th></tr>
{% for c in p.contracts %}
<tr>
<td><strong>{{ c.code }}</strong></td>
<td>{{ c.name }}</td>
<td>
{% if c.is_active %}
<span class="badge badge-up">启用</span>
{% else %}
<span class="badge badge-down">停用</span>
{% endif %}
</td>
<td><a href="/contracts/{{ c.code }}" style="color:var(--accent);text-decoration:none;">查看 →</a></td>
<td>
<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;">
{% if c.is_active %}停用{% else %}启用{% endif %}
</button>
</form>
<form method="post" action="/admin/contract/{{ c.id }}/delete" style="display:inline;" onsubmit="return confirm('删除 {{ c.code }}?此操作不可恢复。')">
<button style="background:none;border:none;color:var(--danger);cursor:pointer;font-size:0.82rem;">删除</button>
</form>
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
<form method="post" action="/admin/product/{{ p.id }}/delete" onsubmit="return confirm('删除品种 {{ p.code }} 及其所有合约?')" style="text-align:right;margin-bottom:28px;">
<button style="background:none;border:none;color:var(--danger);cursor:pointer;font-size:0.82rem;">删除品种 {{ p.code }}</button>
</form>
{% else %}
<div style="text-align:center;padding:40px;color:var(--sub);">暂无品种,请先新建。</div>
{% endfor %}
{% endblock %}
+69
View File
@@ -0,0 +1,69 @@
{% extends "base.html" %}
{% block title %}博弈分析{% endblock %}
{% block heading %}博弈分析{% endblock %}
{% block breadcrumb %}机构持仓{% endblock %}
{% block content %}
{% if latest_date %}
<div class="stat-grid">
<div class="stat-card">
<div class="label">数据日期</div>
<div class="value">{{ latest_date }}</div>
</div>
<div class="stat-card">
<div class="label">参考现价</div>
<div class="value">{{ current_price }}</div>
</div>
<div class="stat-card">
<div class="label">六家净空</div>
<div class="value">{{ total_net_short }}</div>
</div>
<div class="stat-card">
<div class="label">浮动盈亏</div>
<div class="value">{{ total_pnl }}</div>
</div>
</div>
<div class="section-title">机构持仓明细</div>
<div class="table-wrap">
<table>
<tr>
<th>机构</th><th>多单</th><th>空单</th><th>多单成本</th><th>空单成本</th><th>净持仓</th><th>净盈亏</th>
</tr>
{% for r in rows %}
<tr>
<td><strong>{{ r.institution }}</strong></td>
<td>{{ r.long_pos }}</td>
<td>{{ r.short_pos }}</td>
<td>{{ r.long_cost }}</td>
<td>{{ r.short_cost }}</td>
<td>{{ r.net_pos }}</td>
<td>
{% if r.pnl_raw > 0 %}
<span class="badge badge-up">{{ r.pnl }}</span>
{% elif r.pnl_raw < 0 %}
<span class="badge badge-down">{{ r.pnl }}</span>
{% else %}
<span class="na"></span>
{% endif %}
</td>
</tr>
{% endfor %}
<tr style="font-weight:700; background:var(--th-bg);">
<td>合计</td>
<td>{{ totals.long_pos }}</td>
<td>{{ totals.short_pos }}</td>
<td></td>
<td></td>
<td>{{ totals.net_pos }}</td>
<td>{{ totals.pnl }}</td>
</tr>
</table>
</div>
{% else %}
<div style="text-align:center;padding:60px 0;color:var(--sub);">
<p style="font-size:1.1rem;">暂无持仓数据</p>
<p style="margin-top:8px;"><a href="/input/" style="color:var(--accent);">前往录入 →</a></p>
</div>
{% endif %}
{% endblock %}
+263
View File
@@ -0,0 +1,263 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}期货量化{% endblock %}</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f8fafc; --surface: #fff; --fg: #1e293b; --sub: #64748b;
--border: #e2e8f0; --accent: #3b82f6; --accent-light: #eff6ff;
--th-bg: #f1f5f9; --th-fg: #475569;
--danger: #ef4444; --danger-bg: #fef2f2; --danger-fg: #dc2626;
--success: #10b981; --success-bg: #ecfdf5; --success-fg: #059669;
--warn: #f59e0b; --warn-bg: #fffbeb; --warn-fg: #d97706;
--na: #94a3b8;
--sidebar-w: 220px; --header-h: 56px;
}
[data-theme="dark"] {
--bg: #0f172a; --surface: #1e293b; --fg: #e2e8f0; --sub: #94a3b8;
--border: #334155; --accent: #60a5fa; --accent-light: #1e3a5f;
--th-bg: #1e293b; --th-fg: #cbd5e1;
--danger: #f87171; --danger-bg: #3b1515; --danger-fg: #fca5a5;
--success: #34d399; --success-bg: #0a1f17; --success-fg: #6ee7b7;
--warn: #fbbf24; --warn-bg: #2d2110; --warn-fg: #fcd34d;
--na: #64748b;
}
body {
font-family: -apple-system, 'Segoe UI', system-ui, sans-serif;
color: var(--fg); background: var(--bg);
display: flex; min-height: 100vh;
}
/* ── Sidebar ── */
.sidebar {
width: var(--sidebar-w); min-width: var(--sidebar-w);
background: var(--surface); border-right: 1px solid var(--border);
display: flex; flex-direction: column; position: fixed;
top: 0; left: 0; bottom: 0; z-index: 50;
padding: 0;
}
.sidebar-logo {
height: var(--header-h); display: flex; align-items: center;
padding: 0 20px; font-size: 1rem; font-weight: 700;
letter-spacing: 0.02em; border-bottom: 1px solid var(--border);
}
.sidebar-nav { flex: 1; padding: 12px 0; }
.sidebar-nav a {
display: flex; align-items: center; gap: 10px;
padding: 10px 20px; color: var(--sub); text-decoration: none;
font-size: 0.9rem; transition: background .12s, color .12s;
border-left: 3px solid transparent;
}
.sidebar-nav a:hover { background: var(--accent-light); color: var(--fg); }
.sidebar-nav a.active {
color: var(--accent); background: var(--accent-light);
border-left-color: var(--accent); font-weight: 600;
}
.sidebar-nav .icon { font-size: 1.1rem; width: 22px; text-align: center; }
.sidebar-footer {
padding: 16px 20px; border-top: 1px solid var(--border);
}
.theme-btn {
width: 100%; padding: 8px; border: 1px solid var(--border); border-radius: 6px;
background: var(--surface); color: var(--fg); cursor: pointer;
font-size: 0.82rem; transition: background .12s;
}
.theme-btn:hover { background: var(--th-bg); }
/* ── Main ── */
.main {
margin-left: var(--sidebar-w); flex: 1;
display: flex; flex-direction: column; min-height: 100vh;
}
.main-header {
height: var(--header-h); min-height: var(--header-h);
padding: 0 28px; display: flex; align-items: center;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.main-header h1 { font-size: 1.15rem; font-weight: 600; }
.breadcrumb { color: var(--sub); font-size: 0.82rem; margin-left: 12px; }
.breadcrumb a { color: var(--sub); text-decoration: none; }
.breadcrumb a:hover { color: var(--accent); }
.main-content { flex: 1; padding: 24px 28px; }
/* ── Components ── */
.stat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
.stat-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: 10px; padding: 18px 20px;
}
.stat-card .label { font-size: 0.78rem; color: var(--sub); margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.04em; }
.stat-card .value { font-size: 1.5rem; font-weight: 700; }
.stat-card .trend { font-size: 0.8rem; margin-top: 4px; }
.trend-up { color: var(--success-fg); }
.trend-down { color: var(--danger-fg); }
.section-title {
font-size: 0.85rem; font-weight: 600; color: var(--sub);
text-transform: uppercase; letter-spacing: 0.04em;
margin-bottom: 12px; padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
/* ── Table ── */
.table-wrap {
background: var(--surface); border: 1px solid var(--border);
border-radius: 10px; overflow: hidden; margin-bottom: 24px;
}
table { border-collapse: collapse; width: 100%; font-size: 0.88rem; }
th {
background: var(--th-bg); color: var(--th-fg); font-weight: 600;
padding: 10px 14px; border-bottom: 2px solid var(--border);
text-align: center; white-space: nowrap; font-size: 0.8rem;
text-transform: uppercase; letter-spacing: 0.03em;
}
td { padding: 9px 14px; border-bottom: 1px solid var(--border); text-align: center; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: var(--accent-light); }
td:first-child { font-weight: 500; }
tr.active td { background: #dbeafe !important; }
[data-theme="dark"] tr.active td { background: #1e3a5f !important; }
.na { color: var(--na); }
/* ── Badges ── */
.badge { display: inline-block; padding: 2px 10px; border-radius: 20px; font-size: 0.78rem; font-weight: 600; }
.badge-up { background: var(--success-bg); color: var(--success-fg); }
.badge-down { background: var(--danger-bg); color: var(--danger-fg); }
.badge-warn { background: var(--warn-bg); color: var(--warn-fg); }
/* ── Forms ── */
.form-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: 10px; padding: 24px; max-width: 520px;
}
.form-group { margin-bottom: 16px; }
.form-group label {
display: block; font-size: 0.8rem; font-weight: 600;
color: var(--sub); margin-bottom: 5px;
text-transform: uppercase; letter-spacing: 0.03em;
}
.form-group input, .form-group select {
width: 100%; padding: 9px 12px; border: 1px solid var(--border);
border-radius: 6px; font-size: 0.9rem;
background: var(--bg); color: var(--fg);
transition: border-color .15s;
}
.form-group input:focus, .form-group select:focus {
outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-light);
}
.btn {
padding: 9px 22px; border: none; border-radius: 6px;
font-size: 0.88rem; font-weight: 600; cursor: pointer;
transition: background .12s;
}
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover { opacity: 0.9; }
/* ── Drawer ── */
.drawer-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.35); z-index: 99; display: none; }
.drawer-overlay.show { display: block; }
.drawer { position: fixed; top: 0; right: 0; width: 360px; height: 100%; background: var(--surface); border-left: 1px solid var(--border); z-index: 100; padding: 28px 24px; transform: translateX(100%); transition: transform .25s ease; overflow-y: auto; box-shadow: -4px 0 24px rgba(0,0,0,.12); }
.drawer.show { transform: translateX(0); }
.drawer h3 { font-size: 1rem; margin-bottom: 4px; }
.drawer .date-label { color: var(--sub); font-size: 0.82rem; margin-bottom: 18px; }
.drawer .calc-table { width: 100%; border-collapse: collapse; font-size: 0.82rem; margin-bottom: 16px; }
.drawer .calc-table td { padding: 5px 10px; border: 1px solid var(--border); text-align: center; }
.drawer .calc-table tr:first-child td { background: var(--th-bg); font-weight: 600; color: var(--th-fg); }
.drawer .result { font-size: 0.85rem; line-height: 2; color: var(--fg); }
.drawer .result b { font-size: 1.15rem; }
.drawer .close-btn { position: absolute; top: 16px; right: 20px; background: none; border: none; font-size: 1.4rem; cursor: pointer; color: var(--sub); line-height: 1; }
.drawer .close-btn:hover { color: var(--fg); }
.amp-cell { cursor: pointer; color: var(--accent); font-weight: 600; }
.amp-cell:hover { text-decoration: underline; }
/* ── Tags / Chips ── */
.tag-row { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 20px; }
.tag {
padding: 5px 12px; border-radius: 20px; font-size: 0.78rem;
font-weight: 500; cursor: pointer; text-decoration: none;
border: 1px solid var(--border); color: var(--sub);
transition: all .12s;
}
.tag:hover, .tag.active { border-color: var(--accent); color: var(--accent); background: var(--accent-light); }
</style>
</head>
<body>
<aside class="sidebar">
<div class="sidebar-logo">📊 期货量化</div>
<nav class="sidebar-nav">
<a href="/contracts/" class="{% if active_nav == 'contracts' %}active{% endif %}">
<span class="icon">📈</span> 行情数据
</a>
<a href="/analysis/" class="{% if active_nav == 'analysis' %}active{% endif %}">
<span class="icon">⚔️</span> 博弈分析
</a>
<a href="/input/" class="{% if active_nav == 'input' %}active{% endif %}">
<span class="icon">📝</span> 数据录入
</a>
<a href="/admin/" class="{% if active_nav == 'admin' %}active{% endif %}">
<span class="icon">⚙️</span> 系统管理
</a>
</nav>
<div class="sidebar-footer">
<button class="theme-btn" id="themeToggle">🌙 暗色模式</button>
</div>
</aside>
<div class="main">
<header class="main-header">
<h1>{% block heading %}概览{% endblock %}</h1>
<span class="breadcrumb">{% block breadcrumb %}{% endblock %}</span>
</header>
<div class="main-content">
{% block content %}{% endblock %}
</div>
</div>
<div class="drawer-overlay" id="overlay" onclick="closeDrawer()"></div>
<div class="drawer" id="drawer">
<button class="close-btn" onclick="closeDrawer()">&times;</button>
<h3 id="drawer-title"></h3>
<p class="date-label" id="drawer-date"></p>
<table class="calc-table" id="drawer-table"></table>
<div class="result" id="drawer-result"></div>
</div>
<script>
var themeBtn = document.getElementById('themeToggle');
themeBtn.addEventListener('click', toggleTheme);
function setTheme(dark) {
if (dark) {
document.documentElement.setAttribute('data-theme', 'dark');
themeBtn.textContent = '☀️ 亮色模式';
} else {
document.documentElement.removeAttribute('data-theme');
themeBtn.textContent = '🌙 暗色模式';
}
try { localStorage.setItem('ft-theme', dark ? 'dark' : 'light'); } catch(e) {}
}
function toggleTheme() {
setTheme(document.documentElement.getAttribute('data-theme') !== 'dark');
}
(function() {
try {
var s = localStorage.getItem('ft-theme');
if (s === 'dark' || (!s && matchMedia('(prefers-color-scheme: dark)').matches)) setTheme(true);
} catch(e) {}
})();
var activeRow = null;
function closeDrawer() {
document.getElementById('overlay').classList.remove('show');
document.getElementById('drawer').classList.remove('show');
if (activeRow) { activeRow.classList.remove('active'); activeRow = null; }
}
</script>
</body>
</html>
+86
View File
@@ -0,0 +1,86 @@
{% extends "base.html" %}
{% block title %}{{ contract }}{% endblock %}
{% block heading %}{{ contract }}{% endblock %}
{% block breadcrumb %}<a href="/contracts/">行情数据</a> / {{ contract }}{% endblock %}
{% block content %}
{% if latest %}
<div class="stat-grid">
<div class="stat-card">
<div class="label">最新收盘</div>
<div class="value">{{ latest.close|int }}</div>
<div class="trend">日期 {{ latest.date }}</div>
</div>
<div class="stat-card">
<div class="label">当日振幅</div>
<div class="value">{{ latest.amp_5d|int if latest.amp_5d else '—' }}</div>
<div class="trend">日波幅 {{ latest.diff|int }} 点</div>
</div>
<div class="stat-card">
<div class="label">数据条数</div>
<div class="value">{{ row_count }}</div>
</div>
</div>
{% endif %}
<div class="section-title">日线数据</div>
<p style="font-size:0.78rem;color:var(--sub);margin-bottom:12px;">
振幅 = 近 5 日 (最高−最低) 均值取整 · 点击振幅值查看计算过程
</p>
<div class="table-wrap">
<table id="bars-table">
<tr><th>日期</th><th>星期</th><th>开盘</th><th>收盘</th><th>最高</th><th>最低</th><th>波幅</th><th>5日均振幅</th></tr>
{% for row in rows %}
<tr data-index="{{ loop.index0 }}" data-diff="{{ row.diff }}" data-date="{{ row.date }}" data-weekday="{{ row.weekday }}">
<td>{{ row.date }}</td>
<td>{{ row.weekday }}</td>
<td>{{ row.open }}</td>
<td>{{ row.close }}</td>
<td>{{ row.high }}</td>
<td>{{ row.low }}</td>
<td>{{ row.diff }}</td>
<td>
{% if row.has_amp %}
<span class="amp-cell">{{ row.amp_5d }}</span>
{% else %}
<span class="na"></span>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
</div>
<script>
document.querySelectorAll('.amp-cell').forEach(function(cell) {
cell.addEventListener('click', function() {
var row = cell.parentElement.parentElement;
var tbl = document.getElementById('bars-table');
var rows = tbl.querySelectorAll('tr');
var idx = parseInt(row.dataset.index);
if (idx < 5) return;
document.getElementById('drawer-title').textContent = '{{ contract }}';
document.getElementById('drawer-date').textContent = '目标: ' + row.dataset.date + ' ' + row.dataset.weekday + ' 振幅 ' + cell.textContent.trim();
var html = '<tr><td>日期</td><td>最高−最低</td></tr>';
var sum = 0;
for (var j = idx - 5; j < idx; j++) {
var r = rows[j + 1];
html += '<tr><td>' + r.dataset.date + ' ' + r.dataset.weekday + '</td><td>' + r.dataset.diff + '</td></tr>';
sum += parseInt(r.dataset.diff);
}
document.getElementById('drawer-table').innerHTML = html;
document.getElementById('drawer-result').innerHTML = '合计 <b>' + sum + '</b> &divide; 5 = <b>' + (sum / 5).toFixed(1) + '</b><br>四舍五入 &rarr; <b>' + Math.round(sum / 5) + '</b>';
document.getElementById('overlay').classList.add('show');
document.getElementById('drawer').classList.add('show');
if (activeRow) activeRow.classList.remove('active');
row.classList.add('active');
activeRow = row;
});
});
</script>
{% endblock %}
+56
View File
@@ -0,0 +1,56 @@
{% extends "base.html" %}
{% block title %}行情数据{% endblock %}
{% block heading %}行情数据{% endblock %}
{% block breadcrumb %}<a href="/contracts/">合约总览</a>{% endblock %}
{% block content %}
<div class="stat-grid">
<div class="stat-card">
<div class="label">监控合约</div>
<div class="value">{{ contracts|length }}</div>
</div>
<div class="stat-card">
<div class="label">数据条数</div>
<div class="value">{{ total_bars }}</div>
</div>
<div class="stat-card">
<div class="label">最新日期</div>
<div class="value">{{ latest_date }}</div>
</div>
</div>
<div class="section-title">合约列表</div>
<div class="tag-row">
{% for c in contracts %}
<a class="tag" href="/contracts/{{ c }}">{{ c }}</a>
{% endfor %}
</div>
<div class="table-wrap">
<table>
<tr><th>合约</th><th>日期</th><th>开盘</th><th>收盘</th><th>最高</th><th>最低</th><th>振幅</th><th>操作</th></tr>
{% for c in contracts %}
<tr>
<td><strong>{{ c }}</strong></td>
{% if c in contract_bars %}
<td>{{ contract_bars[c].date }}</td>
<td>{{ contract_bars[c].open|int }}</td>
<td>{{ contract_bars[c].close|int }}</td>
<td>{{ contract_bars[c].high|int }}</td>
<td>{{ contract_bars[c].low|int }}</td>
<td>
{% if contract_bars[c].amp_5d %}
<span class="badge badge-up">{{ contract_bars[c].amp_5d|int }}</span>
{% else %}
<span class="na"></span>
{% endif %}
</td>
{% else %}
<td colspan="6" class="na">暂无数据</td>
{% endif %}
<td><a href="/contracts/{{ c }}" style="color:var(--accent);text-decoration:none;">详情 →</a></td>
</tr>
{% endfor %}
</table>
</div>
{% endblock %}
+45
View File
@@ -0,0 +1,45 @@
{% extends "base.html" %}
{% block title %}数据录入{% endblock %}
{% block heading %}数据录入{% endblock %}
{% block breadcrumb %}机构持仓录入{% endblock %}
{% block content %}
<div class="form-card">
<div class="section-title">持仓快照</div>
<form method="post" action="/input">
<div class="form-group">
<label>机构</label>
<select name="institution" required>
{% for inst in institutions %}
<option value="{{ inst }}">{{ inst }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>方向</label>
<select name="direction" required>
<option value="long">多单</option>
<option value="short">空单</option>
</select>
</div>
<div class="form-group">
<label>日期</label>
<input type="date" name="date_str" required>
</div>
<div class="form-group">
<label>持仓量(手)</label>
<input type="number" name="position" required placeholder="例: 193030">
</div>
<div class="form-group">
<label>持仓均价</label>
<input type="number" name="avg_cost" step="0.01" required placeholder="例: 995.26">
</div>
<button type="submit" class="btn btn-primary">提交</button>
</form>
</div>
{% endblock %}
+9
View File
@@ -0,0 +1,9 @@
services:
app:
build: .
ports:
- "8000:8000"
volumes:
- ./data:/app/data
- ../日报:/app/output
restart: unless-stopped
+7
View File
@@ -0,0 +1,7 @@
fastapi==0.115.6
uvicorn==0.34.0
sqlalchemy==2.0.36
jinja2==3.1.4
apscheduler==3.11.0
akshare==1.16.72
python-multipart==0.0.19