Compare commits

...

3 Commits

15 changed files with 310 additions and 528 deletions
+117 -5
View File
@@ -9,6 +9,7 @@ def fetch_contract_bars(contract_code: str, start_date: str | None = None) -> li
"""Fetch daily OHLCV for a single contract from akshare.
Returns list of {date, open, close, high, low} dicts.
Only returns bars on or after start_date when provided.
"""
import akshare as ak
@@ -22,6 +23,8 @@ def fetch_contract_bars(contract_code: str, start_date: str | None = None) -> li
print(f"[collector] No data returned for {contract_code}")
return []
filter_date = date.fromisoformat(start_date) if start_date else None
bars = []
for _, row in df.iterrows():
try:
@@ -31,7 +34,7 @@ def fetch_contract_bars(contract_code: str, start_date: str | None = None) -> li
else:
d = date.fromisoformat(str(val)[:10])
if start_date and d <= date.fromisoformat(start_date):
if filter_date and d < filter_date:
continue
bars.append({
@@ -95,6 +98,7 @@ def _sync_one(db, contract_code: str) -> int:
return 0
inserted = 0
min_date = None
for bar in bars:
existing = (
db.query(DailyBar)
@@ -114,22 +118,130 @@ def _sync_one(db, contract_code: str) -> int:
low=bar["low"],
))
inserted += 1
if min_date is None or bar["date"] < min_date:
min_date = bar["date"]
if inserted > 0:
db.flush()
_recompute_amp(db, contract_code)
_recompute_amp(db, contract_code, from_date=min_date)
return inserted
def _recompute_amp(db, contract_code: str):
"""Recompute amp_5d for all bars of a contract."""
def fetch_position_rankings(contract_code: str, trade_date: str) -> list[dict]:
"""Fetch top-20 position rankings for a contract on a given date from akshare.
Calls the API 3 times (volume, long, short) and returns a unified list of dicts:
{data_type, rank, institution, value, change}
"""
import akshare as ak
results = []
for sym, dtype in [("成交量", "volume"), ("多单持仓", "long"), ("空单持仓", "short")]:
try:
df = ak.futures_hold_pos_sina(symbol=sym, contract=contract_code.upper(), date=trade_date)
except Exception as e:
print(f"[collector] position akshare error for {contract_code} {dtype}: {e}")
continue
if df is None or df.empty:
continue
for _, row in df.iterrows():
try:
results.append({
"data_type": dtype,
"rank": int(row["名次"]),
"institution": str(row["会员简称"]),
"value": int(row.iloc[2]),
"change": int(row["比上交易增减"]),
})
except (KeyError, ValueError, TypeError) as e:
print(f"[collector] position skip row: {e}")
continue
return results
def sync_position_rankings() -> dict:
"""Sync position rankings for all active contracts. Returns {contract_code: new_rows}."""
db = SessionLocal()
results = {}
try:
active_contracts = (
db.query(Contract).filter(Contract.is_active == True).all()
)
for c in active_contracts:
count = _sync_positions_for_contract(db, c.code)
results[c.code] = count
db.commit()
finally:
db.close()
return results
def _sync_positions_for_contract(db, contract_code: str) -> int:
"""Sync position rankings for all dates that have bars but no position data."""
from app.models import PositionRanking
code = contract_code.upper()
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()
]
inserted = 0
for d in bar_dates:
if d in existing_dates:
continue
date_str = d.strftime("%Y%m%d")
rankings = fetch_position_rankings(code, date_str)
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()
return inserted
def _recompute_amp(db, contract_code: str, from_date: date | None = None):
"""Recompute amp_5d for bars of a contract from from_date onwards."""
bars = (
db.query(DailyBar)
.filter(DailyBar.contract == contract_code)
.order_by(DailyBar.date)
.all()
)
start_idx = 0
if from_date:
for i, bar in enumerate(bars):
if bar.date >= from_date:
start_idx = i
break
for i, bar in enumerate(bars):
if i >= 5:
if i >= 5 and i >= start_idx:
bar.amp_5d = compute_amp_5d([b.diff for b in bars[i - 5 : i]])
-26
View File
@@ -1,26 +0,0 @@
"""博弈分析计算引擎"""
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}"
+1 -2
View File
@@ -7,7 +7,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
from app.database import engine, Base, SessionLocal
from app.models import User
from app.seed import seed
from app.routers import contracts, analysis, admin, auth
from app.routers import contracts, admin, auth
TEMPLATES_DIR = Path(__file__).parent / "templates"
@@ -55,7 +55,6 @@ app.add_middleware(AuthMiddleware)
app.include_router(auth.router)
app.include_router(contracts.router)
app.include_router(analysis.router)
app.include_router(admin.router)
+9 -9
View File
@@ -51,18 +51,18 @@ class DailyBar(Base):
return self.high - self.low
class PositionSnapshot(Base):
__tablename__ = "position_snapshots"
__table_args__ = (UniqueConstraint("contract_code", "institution", "direction", "date"),)
class PositionRanking(Base):
__tablename__ = "position_rankings"
__table_args__ = (UniqueConstraint("contract_code", "institution", "data_type", "date"),)
id: Mapped[int] = mapped_column(primary_key=True)
contract_code: Mapped[str] = mapped_column(String(10), index=True, default="FG")
institution: Mapped[str] = mapped_column(String(20), index=True)
direction: Mapped[str] = mapped_column(String(10))
contract_code: Mapped[str] = mapped_column(String(10), index=True)
institution: Mapped[str] = mapped_column(String(30), index=True)
data_type: Mapped[str] = mapped_column(String(10), index=True)
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)
rank: Mapped[int] = mapped_column(Integer)
value: Mapped[int] = mapped_column(Integer)
change: Mapped[int] = mapped_column(Integer)
class User(Base):
+12 -3
View File
@@ -2,8 +2,8 @@ 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, DailyBar, PositionSnapshot
from app.collector import sync_active_contracts, sync_one_contract
from app.models import Product, Contract, DailyBar, PositionRanking
from app.collector import sync_active_contracts, sync_one_contract, sync_position_rankings
router = APIRouter(prefix="/admin", tags=["admin"])
@@ -113,7 +113,7 @@ def delete_contract(contract_id: int, db: Session = Depends(get_db)):
c = db.query(Contract).filter(Contract.id == contract_id).first()
if c:
db.query(DailyBar).filter(DailyBar.contract == c.code).delete()
db.query(PositionSnapshot).filter(PositionSnapshot.contract_code == c.code).delete()
db.query(PositionRanking).filter(PositionRanking.contract_code == c.code).delete()
db.delete(c)
db.commit()
return RedirectResponse("/admin/?tab=contract", status_code=303)
@@ -125,6 +125,7 @@ def delete_product(product_id: int, db: Session = Depends(get_db)):
if p:
for c in p.contracts:
db.query(DailyBar).filter(DailyBar.contract == c.code).delete()
db.query(PositionRanking).filter(PositionRanking.contract_code == c.code).delete()
db.delete(p)
db.commit()
return RedirectResponse("/admin/?tab=product", status_code=303)
@@ -144,6 +145,14 @@ def sync_single(contract_code: str):
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/product/{product_id}")
def sync_product(product_id: int, request: Request, db: Session = Depends(get_db)):
contracts = db.query(Contract).filter(
-125
View File
@@ -1,125 +0,0 @@
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, Contract, DailyBar
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)):
# Get active contracts for selector
active_contracts = (
db.query(Contract.code)
.filter(Contract.is_active == True)
.order_by(Contract.code)
.all()
)
contract_list = [c[0] for c in active_contracts]
# Default to first contract, or use query param
selected = request.query_params.get("contract", contract_list[0] if contract_list else None)
if not selected:
template = request.app.state.templates.get_template("analysis.html")
return HTMLResponse(
template.render(request=request, active_nav="analysis", rows=[], latest_date=None)
)
# Get latest date for selected contract
latest_snap = (
db.query(PositionSnapshot.date)
.filter(PositionSnapshot.contract_code == selected)
.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, contracts=contract_list, selected=selected,
)
)
latest_date = latest_snap[0]
# Get latest close for current_price reference
latest_bar = (
db.query(DailyBar)
.filter(DailyBar.contract == selected)
.order_by(DailyBar.date.desc())
.first()
)
current_price = int(latest_bar.close) if latest_bar else 0
rows = []
total_net_short = 0
pnl_sum = 0.0
for inst in INSTITUTIONS:
long = (
db.query(PositionSnapshot)
.filter(
PositionSnapshot.contract_code == selected,
PositionSnapshot.institution == inst,
PositionSnapshot.direction == "long",
PositionSnapshot.date == latest_date,
)
.first()
)
short = (
db.query(PositionSnapshot)
.filter(
PositionSnapshot.contract_code == selected,
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)
if np < 0:
total_net_short += abs(np)
pnl_sum += pnl
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 "",
"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,
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),
contracts=contract_list,
selected=selected,
)
)
-10
View File
@@ -9,16 +9,6 @@ router = APIRouter(prefix="/auth", tags=["auth"])
SESSION_COOKIE = "ft_session"
def get_current_user(request: Request, db: Session = Depends(get_db)) -> User | None:
user_id = request.cookies.get(SESSION_COOKIE)
if not user_id:
return None
try:
return db.query(User).filter(User.id == int(user_id)).first()
except (ValueError, TypeError):
return None
@router.get("/login", response_class=HTMLResponse)
def login_page(request: Request):
template = request.app.state.templates.get_template("login.html")
+67 -11
View File
@@ -1,13 +1,15 @@
import math
from datetime import date, timedelta
from fastapi import APIRouter, Depends, Request
from fastapi import APIRouter, Depends, Request, Query
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import DailyBar, Contract
from app.models import DailyBar, Contract, PositionRanking
router = APIRouter(prefix="/contracts", tags=["contracts"])
WEEKDAY_ZH = {0: "周一", 1: "周二", 2: "周三", 3: "周四", 4: "周五", 5: "周六", 6: "周日"}
PAGE_SIZE = 7
def get_active_contracts(db: Session) -> list[str]:
@@ -35,11 +37,6 @@ def contract_index(request: Request, db: Session = Depends(get_db)):
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(
@@ -47,14 +44,18 @@ def contract_index(request: Request, db: Session = Depends(get_db)):
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)):
def contract_detail(
request: Request,
contract: str,
pos_date: str | None = None,
page: int = Query(1, ge=1),
db: Session = Depends(get_db),
):
active_contracts = get_active_contracts(db)
bars = (
db.query(DailyBar)
@@ -63,9 +64,17 @@ def contract_detail(request: Request, contract: str, db: Session = Depends(get_d
.all()
)
total = len(bars)
total_pages = max(1, math.ceil(total / PAGE_SIZE))
page = min(page, total_pages)
start = (page - 1) * PAGE_SIZE
page_bars = bars[start:start + PAGE_SIZE]
rows = []
for bar in bars:
for i, bar in enumerate(page_bars):
global_idx = start + i
rows.append({
"global_idx": global_idx,
"date": bar.date.strftime("%Y/%-m/%-d"),
"weekday": WEEKDAY_ZH.get(bar.date.weekday(), ""),
"open": int(bar.open) if bar.open else "-",
@@ -79,6 +88,45 @@ def contract_detail(request: Request, contract: str, db: Session = Depends(get_d
latest = bars[0] if bars else None
# Dates that have position data (for date picker)
pos_dates = [
r[0] for r in
db.query(PositionRanking.date)
.filter(PositionRanking.contract_code == contract.upper())
.distinct()
.order_by(PositionRanking.date.desc())
.all()
]
# Determine which date to show position rankings for
if pos_date:
try:
selected_date = date.fromisoformat(pos_date)
except ValueError:
selected_date = pos_dates[0] if pos_dates else None
else:
selected_date = pos_dates[0] if pos_dates else None
# Position rankings for the selected date
pos_data = {"volume": [], "long": [], "short": []}
if selected_date:
rankings = (
db.query(PositionRanking)
.filter(
PositionRanking.contract_code == contract.upper(),
PositionRanking.date == selected_date,
)
.order_by(PositionRanking.data_type, PositionRanking.rank)
.all()
)
for r in rankings:
pos_data[r.data_type].append({
"rank": r.rank,
"institution": r.institution,
"value": r.value,
"change": r.change,
})
# Predict next trading day amplitude: mean of latest 5 diffs
# Compute next trading date
next_date = latest.date + timedelta(days=1) if latest else None
@@ -98,9 +146,17 @@ def contract_detail(request: Request, contract: str, db: Session = Depends(get_d
contracts=active_contracts,
rows=rows,
latest=latest,
pos_dates=pos_dates,
selected_pos_date=selected_date,
pos_data=pos_data,
next_amp=next_amp,
next_date=next_date.strftime("%Y/%-m/%-d") if next_date else None,
next_weekday=WEEKDAY_ZH.get(next_date.weekday(), "") if next_date else "",
row_count=len(rows),
total_rows=total,
page=page,
total_pages=total_pages,
pos_date=pos_date or "",
WEEKDAY_ZH=WEEKDAY_ZH,
)
)
+2 -202
View File
@@ -1,7 +1,7 @@
"""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, User
from app.models import DailyBar, Product, Contract, User
from app.engine.lock_strategy import compute_amp_5d
# --- Seed OHLCV data ---
@@ -27,176 +27,6 @@ SEED_BARS: list[dict] = [
{"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()
@@ -260,38 +90,8 @@ def seed():
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.contract_code, r.institution, r.direction, r.date)
for r in db.query(
PositionSnapshot.contract_code,
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 ("FG2609", inst, direction, d) not in existing_pos:
snaps.append(
PositionSnapshot(
contract_code="FG2609",
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")
print(f"Seeded {len(bars_to_insert)} bars")
finally:
db.close()
+8 -2
View File
@@ -32,10 +32,16 @@
<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>
<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>
<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>
-75
View File
@@ -1,75 +0,0 @@
{% extends "base.html" %}
{% block title %}博弈分析{% endblock %}
{% block heading %}博弈分析{% endblock %}
{% block breadcrumb %}机构持仓{% endblock %}
{% block content %}
{% if contracts %}
<div style="display:flex;align-items:center;gap:12px;margin-bottom:20px;">
<span style="font-size:0.85rem;color:var(--sub);">合约</span>
<select onchange="window.location='?contract='+this.value" style="padding:6px 12px;border:1px solid var(--border);border-radius:6px;background:var(--surface);color:var(--fg);font-size:0.88rem;">
{% for c in contracts %}
<option value="{{ c }}"{% if c == selected %} selected{% endif %}>{{ c }}</option>
{% endfor %}
</select>
</div>
{% endif %}
{% 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">{{ selected }} 现价</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>
</tr>
{% for r in rows %}
<tr>
<td><strong>{{ r.institution }}</strong></td>
<td>{{ r.long_pos }}</td>
<td>{{ r.short_pos }}</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></td>
<td></td>
<td>{{ total_net_short }}</td>
<td>{{ total_pnl }}</td>
</tr>
</table>
</div>
{% else %}
<div style="text-align:center;padding:60px 0;color:var(--sub);">
<p style="font-size:1.1rem;">暂无「{{ selected }}」的持仓数据</p>
</div>
{% endif %}
{% endblock %}
+13 -11
View File
@@ -177,15 +177,18 @@
.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;
input[type="date"]::-webkit-calendar-picker-indicator {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23475669' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'/%3E%3Cline x1='16' y1='2' x2='16' y2='6'/%3E%3Cline x1='8' y1='2' x2='8' y2='6'/%3E%3Cline x1='3' y1='10' x2='21' y2='10'/%3E%3C/svg%3E");
cursor: pointer;
}
.tag:hover, .tag.active { border-color: var(--accent); color: var(--accent); background: var(--accent-light); }
[data-theme="dark"] input[type="date"]::-webkit-calendar-picker-indicator {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'/%3E%3Cline x1='16' y1='2' x2='16' y2='6'/%3E%3Cline x1='8' y1='2' x2='8' y2='6'/%3E%3Cline x1='3' y1='10' x2='21' y2='10'/%3E%3C/svg%3E");
filter: none;
}
[data-theme="dark"] input[type="date"] {
color-scheme: dark;
}
</style>
</head>
<body>
@@ -195,9 +198,6 @@
<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="/admin/" class="{% if active_nav == 'admin' %}active{% endif %}">
<span class="icon">⚙️</span> 系统管理
</a>
@@ -236,9 +236,11 @@ themeBtn.addEventListener('click', toggleTheme);
function setTheme(dark) {
if (dark) {
document.documentElement.setAttribute('data-theme', 'dark');
document.documentElement.style.colorScheme = 'dark';
themeBtn.textContent = '☀️ 亮色模式';
} else {
document.documentElement.removeAttribute('data-theme');
document.documentElement.style.colorScheme = 'light';
themeBtn.textContent = '🌙 暗色模式';
}
try { localStorage.setItem('ft-theme', dark ? 'dark' : 'light'); } catch(e) {}
+79 -28
View File
@@ -10,15 +10,13 @@
<p style="font-size:0.78rem;color:var(--sub);margin:0;">
振幅 = 近 5 日 (最高−最低) 均值取整 · 点击振幅值查看计算过程
</p>
{% if rows|length > 7 %}
<button id="toggle-all" class="btn" style="font-size:0.78rem;padding:4px 14px;background:var(--surface);color:var(--accent);border:1px solid var(--accent);border-radius:5px;cursor:pointer;" onclick="toggleAll()">显示全部 ({{ rows|length }}条)</button>
{% endif %}
<span style="font-size:0.78rem;color:var(--sub);">共 {{ total_rows }} 条</span>
</div>
<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>
{% if next_date %}
{% if page == 1 and next_date %}
<tr style="background:var(--accent-light);font-weight:500;">
<td>{{ next_date }}</td>
<td>{{ next_weekday }}</td>
@@ -31,7 +29,7 @@
</tr>
{% endif %}
{% for row in rows %}
<tr data-index="{{ loop.index0 }}" data-diff="{{ row.diff }}" data-date="{{ row.date }}" data-weekday="{{ row.weekday }}"{% if loop.index0 >= 7 %} class="extra-row" style="display:none;"{% endif %}>
<tr data-index="{{ row.global_idx }}" data-diff="{{ row.diff }}" data-date="{{ row.date }}" data-weekday="{{ row.weekday }}">
<td>{{ row.date }}</td>
<td>{{ row.weekday }}</td>
<td>{{ row.open }}</td>
@@ -51,48 +49,101 @@
</table>
</div>
<script>
{% if total_pages > 1 %}
<div style="display:flex;align-items:center;justify-content:center;gap:6px;margin-bottom:24px;">
{% if page > 1 %}
<a class="btn" href="?page={{ page - 1 }}{% if pos_date %}&pos_date={{ pos_date }}{% endif %}" style="font-size:0.82rem;padding:5px 14px;background:var(--surface);color:var(--fg);border:1px solid var(--border);border-radius:5px;text-decoration:none;">← 上一页</a>
{% endif %}
var allShown = false;
function toggleAll() {
allShown = !allShown;
var btn = document.getElementById('toggle-all');
var rows = document.querySelectorAll('.extra-row');
rows.forEach(function(r) { r.style.display = allShown ? '' : 'none'; });
btn.textContent = allShown ? '收起' : '显示全部 ({{ rows|length }}条)';
{% for p in range(1, total_pages + 1) %}
{% if p == page %}
<span style="font-size:0.82rem;padding:5px 12px;background:var(--accent);color:#fff;border-radius:5px;font-weight:600;">{{ p }}</span>
{% elif p <= 3 or p > total_pages - 3 or (p >= page - 1 and p <= page + 1) %}
<a class="btn" href="?page={{ p }}{% if pos_date %}&pos_date={{ pos_date }}{% endif %}" style="font-size:0.82rem;padding:5px 12px;background:var(--surface);color:var(--fg);border:1px solid var(--border);border-radius:5px;text-decoration:none;">{{ p }}</a>
{% elif p == 4 or p == total_pages - 3 %}
<span style="color:var(--sub);padding:5px 4px;"></span>
{% endif %}
{% endfor %}
{% if page < total_pages %}
<a class="btn" href="?page={{ page + 1 }}{% if pos_date %}&pos_date={{ pos_date }}{% endif %}" style="font-size:0.82rem;padding:5px 14px;background:var(--surface);color:var(--fg);border:1px solid var(--border);border-radius:5px;text-decoration:none;">下一页 →</a>
{% endif %}
</div>
{% endif %}
{% if pos_dates %}
<div class="section-title" style="margin-top:28px;display:flex;align-items:center;gap:12px;">
<span>持仓排名 · 前20</span>
<input type="date" id="pos-date-select" value="{{ selected_pos_date }}" min="{{ pos_dates[-1] }}" max="{{ pos_dates[0] }}" onchange="changePosDate(this.value)" style="font-size:0.82rem;padding:4px 10px;border:1px solid var(--border);border-radius:5px;background:var(--surface);color:var(--fg);cursor:pointer;">
</div>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-bottom:24px;">
{% set types = [('volume', '成交量'), ('long', '多单持仓'), ('short', '空单持仓')] %}
{% for dtype, dlabel in types %}
<div class="table-wrap" style="margin-bottom:0;">
<table style="font-size:0.82rem;">
<tr><th colspan="4" style="font-size:0.8rem;color:var(--accent);">{{ dlabel }}</th></tr>
<tr><th>#</th><th>会员</th><th>持仓</th><th>增减</th></tr>
{% for row in pos_data[dtype] %}
<tr>
<td style="color:var(--sub);">{{ row.rank }}</td>
<td style="text-align:left;">{{ row.institution }}</td>
<td>{{ row.value }}</td>
<td style="color:{% if row.change > 0 %}var(--success-fg){% elif row.change < 0 %}var(--danger-fg){% else %}var(--sub){% endif %};">
{% if row.change > 0 %}+{% endif %}{{ row.change }}
</td>
</tr>
{% endfor %}
</table>
</div>
{% endfor %}
</div>
{% endif %}
<script>
function changePosDate(d) {
var url = new URL(window.location);
url.searchParams.set('pos_date', d);
window.location = url;
}
document.querySelectorAll('.amp-cell').forEach(function(cell) {
cell.addEventListener('click', function() {
var row = cell.parentElement.parentElement;
var tbl = document.getElementById('bars-table');
var dataRows = tbl.querySelectorAll('tr[data-index]');
// Next-day row: use latest 5 data rows
// Next-day prediction row
if (cell.dataset.next) {
var dataRows = document.querySelectorAll('#bars-table tr[data-index]');
if (dataRows.length < 5) return;
showDrawer(cell, '{{ contract }}', dataRows, 0, 4);
var rows = Array.from(dataRows).slice(0, 5);
showDrawer(cell, '{{ contract }}', rows, true);
return;
}
var idx = parseInt(row.dataset.index);
// Need 5 older rows (higher index, since newest first)
if (idx + 5 >= dataRows.length) return;
showDrawer(cell, '{{ contract }}', dataRows, idx + 1, idx + 5);
// Collect 5 older rows via DOM navigation (next siblings in the table)
var row = cell.parentElement.parentElement;
var olderRows = [];
var cursor = row;
for (var j = 0; j < 5; j++) {
cursor = cursor.nextElementSibling;
if (!cursor || !cursor.dataset.index) return;
olderRows.push(cursor);
}
showDrawer(cell, '{{ contract }}', olderRows, false);
});
});
function showDrawer(cell, product, dataRows, fromIdx, toIdx) {
function showDrawer(cell, product, calcRows, isNext) {
document.getElementById('drawer-title').textContent = product;
var label = cell.dataset.next ? '次日预测' : '目标';
var label = isNext ? '次日预测' : '目标';
document.getElementById('drawer-date').textContent = label + ' 振幅 ' + cell.textContent.trim();
var html = '<tr><td>日期</td><td>最高−最低</td></tr>';
var sum = 0;
for (var j = fromIdx; j <= toIdx; j++) {
var r = dataRows[j];
calcRows.forEach(function(r) {
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>';
-19
View File
@@ -4,27 +4,8 @@
{% 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>
+2
View File
@@ -81,9 +81,11 @@ themeBtn.addEventListener('click', toggleTheme);
function setTheme(dark) {
if (dark) {
document.documentElement.setAttribute('data-theme', 'dark');
document.documentElement.style.colorScheme = 'dark';
themeBtn.textContent = '☀️ 亮色模式';
} else {
document.documentElement.removeAttribute('data-theme');
document.documentElement.style.colorScheme = 'light';
themeBtn.textContent = '🌙 暗色模式';
}
try { localStorage.setItem('ft-theme', dark ? 'dark' : 'light'); } catch(e) {}