Compare commits

...

2 Commits

Author SHA1 Message Date
fish 58d99259e8 更新数据库文件
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-26 10:21:07 +08:00
fish 2065122777 新增仓位管理功能,支持按轮跟踪锁仓状态
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-26 10:20:54 +08:00
6 changed files with 390 additions and 5 deletions
+2 -1
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, admin, auth
from app.routers import contracts, admin, auth, positions
TEMPLATES_DIR = Path(__file__).parent / "templates"
@@ -56,6 +56,7 @@ app.add_middleware(AuthMiddleware)
app.include_router(auth.router)
app.include_router(contracts.router)
app.include_router(admin.router)
app.include_router(positions.router)
@app.get("/")
+33
View File
@@ -87,3 +87,36 @@ class User(Base):
return dk.hex() == dk_hex
except (ValueError, AttributeError):
return False
class Round(Base):
__tablename__ = "rounds"
id: Mapped[int] = mapped_column(primary_key=True)
contract_code: Mapped[str] = mapped_column(String(10), index=True)
daily_hands: Mapped[int] = mapped_column(Integer, default=1)
status: Mapped[str] = mapped_column(String(20), default="active")
started_at: Mapped[date] = mapped_column(Date)
positions: Mapped[list["Position"]] = relationship(
back_populates="round", cascade="all, delete-orphan"
)
@property
def total_locks(self) -> int:
return sum(p.locked_count for p in self.positions)
class Position(Base):
__tablename__ = "positions"
id: Mapped[int] = mapped_column(primary_key=True)
round_id: Mapped[int] = mapped_column(ForeignKey("rounds.id"), index=True)
open_price: Mapped[int] = mapped_column(Integer)
amp_threshold: Mapped[int] = mapped_column(Integer)
lock_price: Mapped[int] = mapped_column(Integer)
hands: Mapped[int] = mapped_column(Integer, default=1)
locked_count: Mapped[int] = mapped_column(Integer, default=0)
opened_at: Mapped[date] = mapped_column(Date)
round: Mapped["Round"] = relationship(back_populates="positions")
+117
View File
@@ -0,0 +1,117 @@
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 Round, Position, Contract
router = APIRouter(prefix="/positions", tags=["positions"])
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 positions_page(request: Request, db: Session = Depends(get_db)):
active_round = (
db.query(Round)
.filter(Round.status == "active")
.order_by(Round.started_at.desc())
.first()
)
template = request.app.state.templates.get_template("positions.html")
return HTMLResponse(
template.render(
request=request,
active_nav="positions",
contracts=get_active_contracts(db),
active_round=active_round,
)
)
@router.post("/round")
def create_round(
request: Request,
contract_code: str = Form(...),
daily_hands: int = Form(1),
db: Session = Depends(get_db),
):
existing = db.query(Round).filter(Round.status == "active").first()
if existing:
return RedirectResponse("/positions/?error=已有活跃轮次", status_code=303)
r = Round(
contract_code=contract_code.upper(),
daily_hands=daily_hands,
status="active",
started_at=date.today(),
)
db.add(r)
db.commit()
return RedirectResponse("/positions/", status_code=303)
@router.post("/add")
def add_position(
request: Request,
open_price: int = Form(...),
amp_threshold: int = Form(...),
hands: int = Form(1),
db: Session = Depends(get_db),
):
active_round = db.query(Round).filter(Round.status == "active").first()
if not active_round:
return RedirectResponse("/positions/?error=无活跃轮次", status_code=303)
lock_price = open_price + amp_threshold
p = Position(
round_id=active_round.id,
open_price=open_price,
amp_threshold=amp_threshold,
lock_price=lock_price,
hands=hands,
opened_at=date.today(),
)
db.add(p)
db.commit()
return RedirectResponse("/positions/", status_code=303)
@router.post("/{position_id}/lock")
def lock_position(position_id: int, db: Session = Depends(get_db)):
p = db.query(Position).filter(Position.id == position_id).first()
if p and p.locked_count < p.hands:
p.locked_count += 1
db.commit()
return RedirectResponse("/positions/", status_code=303)
@router.post("/{position_id}/unlock")
def unlock_position(position_id: int, db: Session = Depends(get_db)):
p = db.query(Position).filter(Position.id == position_id).first()
if p and p.locked_count > 0:
p.locked_count -= 1
db.commit()
return RedirectResponse("/positions/", status_code=303)
@router.post("/close")
def close_round(
request: Request,
result: str = Form(...),
db: Session = Depends(get_db),
):
active_round = db.query(Round).filter(Round.status == "active").first()
if active_round:
active_round.status = result
db.commit()
return RedirectResponse("/positions/", status_code=303)
+24 -3
View File
@@ -59,6 +59,12 @@
border-left-color: var(--accent); font-weight: 600;
}
.sidebar-nav .icon { font-size: 1.1rem; width: 22px; text-align: center; }
.nav-parent { position: relative; }
.nav-arrow { font-size: 0.65rem; margin-left: auto; transition: transform .2s; color: var(--sub); }
.nav-group.open .nav-arrow { transform: rotate(-180deg); }
.nav-children { display: none; }
.nav-group.open .nav-children { display: block; }
.nav-children a { padding-left: 48px !important; font-size: 0.84rem !important; }
.sidebar-footer {
padding: 16px 20px; border-top: 1px solid var(--border);
}
@@ -195,9 +201,20 @@
<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>
<div class="nav-group{% if active_nav in ('contracts', 'positions') %} open{% endif %}" id="nav-market">
<a href="/contracts/" class="nav-parent{% if active_nav in ('contracts', 'positions') %} active{% endif %}" onclick="toggleNavGroup(event, 'nav-market')">
<span class="icon">📈</span> 行情数据
<span class="nav-arrow"></span>
</a>
<div class="nav-children">
<a href="/contracts/" class="{% if active_nav == 'contracts' %}active{% endif %}">
<span class="icon">📋</span> 合约列表
</a>
<a href="/positions/" class="{% if active_nav == 'positions' %}active{% endif %}">
<span class="icon">📐</span> 仓位管理
</a>
</div>
</div>
<a href="/admin/" class="{% if active_nav == 'admin' %}active{% endif %}">
<span class="icon">⚙️</span> 系统管理
</a>
@@ -261,6 +278,10 @@ function closeDrawer() {
document.getElementById('drawer').classList.remove('show');
if (activeRow) { activeRow.classList.remove('active'); activeRow = null; }
}
function toggleNavGroup(e, id) {
e.preventDefault();
document.getElementById(id).classList.toggle('open');
}
</script>
</body>
</html>
+213
View File
@@ -0,0 +1,213 @@
{% extends "base.html" %}
{% block title %}仓位管理{% endblock %}
{% block heading %}仓位管理{% endblock %}
{% block breadcrumb %}振幅锁仓 · 仓位跟踪{% endblock %}
{% block content %}
{% set error = request.query_params.get('error', '') %}
{% if error %}
<div style="background:var(--danger-bg);color:var(--danger-fg);padding:10px 16px;border-radius:6px;margin-bottom:16px;font-size:0.88rem;">{{ error }}</div>
{% endif %}
{% if not active_round %}
{# ═══════════════ 新建轮次 ═══════════════ #}
<div class="section-title">新建轮次</div>
<div class="form-card" style="margin-bottom:28px;">
<form method="post" action="/positions/round">
<div style="display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;">
<div class="form-group" style="margin-bottom:0;flex:1;min-width:140px;">
<label>合约</label>
<select name="contract_code" required>
<option value="">-- 选择合约 --</option>
{% for c in contracts %}
<option value="{{ c }}">{{ c }}</option>
{% endfor %}
</select>
</div>
<div class="form-group" style="margin-bottom:0;flex:1;min-width:100px;">
<label>每日手数 N</label>
<input type="number" name="daily_hands" value="1" min="1" max="10" required>
</div>
<button type="submit" class="btn btn-primary">开始新轮</button>
</div>
</form>
</div>
<div style="text-align:center;padding:60px;color:var(--sub);">
<p style="font-size:1.1rem;margin-bottom:8px;">暂无活跃轮次</p>
<p style="font-size:0.82rem;">创建一轮新的交易,开始跟踪仓位和锁仓状态</p>
</div>
{% else %}
{# ═══════════════ 活跃轮次信息 ═══════════════ #}
{% set locks = active_round.total_locks %}
<div class="stat-grid">
<div class="stat-card">
<div class="label">合约</div>
<div class="value">{{ active_round.contract_code }}</div>
</div>
<div class="stat-card">
<div class="label">每日手数</div>
<div class="value">{{ active_round.daily_hands }} 手/天</div>
</div>
<div class="stat-card">
<div class="label">已开仓天数</div>
<div class="value">{{ active_round.positions|length }} 天</div>
</div>
<div class="stat-card" style="{% if locks >= 3 %}border-color:var(--danger);background:var(--danger-bg);{% endif %}">
<div class="label">累计锁仓</div>
<div class="value" style="{% if locks >= 3 %}color:var(--danger-fg);{% endif %}">
{{ locks }} / 3
{% if locks >= 3 %}⚠ 熔断{% endif %}
</div>
</div>
<div class="stat-card">
<div class="label">开始日期</div>
<div class="value" style="font-size:1rem;">{{ active_round.started_at }}</div>
</div>
</div>
{# ═══════════════ 止盈阈值提示 ═══════════════ #}
{% if active_round.positions|length > 0 %}
{% set latest = active_round.positions[-1] %}
<div style="background:var(--accent-light);border:1px solid var(--accent);border-radius:8px;padding:14px 18px;margin-bottom:24px;display:flex;align-items:center;gap:24px;flex-wrap:wrap;">
<div>
<span style="font-size:0.78rem;color:var(--sub);">止盈阈值</span>
<span style="font-weight:700;font-size:1.1rem;margin-left:8px;">{{ active_round.daily_hands }} × {{ latest.amp_threshold }} = {{ active_round.daily_hands * latest.amp_threshold }} 点</span>
</div>
<div>
<span style="font-size:0.78rem;color:var(--sub);">当前振幅 A</span>
<span style="font-weight:700;font-size:1.1rem;margin-left:8px;">{{ latest.amp_threshold }} 点</span>
</div>
<div>
<span style="font-size:0.78rem;color:var(--sub);">总手数</span>
<span style="font-weight:700;font-size:1.1rem;margin-left:8px;">{{ active_round.daily_hands * active_round.positions|length }}</span>
</div>
</div>
{% endif %}
{# ═══════════════ 添加仓位 ═══════════════ #}
<div class="section-title">添加当日仓位</div>
<div class="form-card" style="margin-bottom:28px;">
<form method="post" action="/positions/add" id="addForm">
<div 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="number" name="open_price" id="openPrice" required placeholder="如 1300" style="font-size:1rem;">
</div>
<div class="form-group" style="margin-bottom:0;flex:1;min-width:120px;">
<label>振幅阈值 A</label>
<input type="number" name="amp_threshold" id="ampThreshold" required placeholder="如 18" style="font-size:1rem;">
</div>
<div class="form-group" style="margin-bottom:0;flex:1;min-width:80px;">
<label>手数</label>
<input type="number" name="hands" value="1" min="1" max="10" required>
</div>
<div style="display:flex;flex-direction:column;align-items:center;min-width:100px;">
<span style="font-size:0.75rem;color:var(--sub);margin-bottom:4px;">锁仓价位</span>
<span id="lockPricePreview" style="font-size:1.3rem;font-weight:700;color:var(--danger-fg);"></span>
</div>
<button type="submit" class="btn btn-primary">添加仓位</button>
</div>
</form>
</div>
{# ═══════════════ 仓位列表 ═══════════════ #}
<div class="section-title">本轮仓位</div>
{% if active_round.positions %}
<div class="table-wrap">
<table>
<tr>
<th>日期</th>
<th>开仓价</th>
<th>振幅 A</th>
<th>锁仓价位</th>
<th>手数</th>
<th>已锁</th>
<th>状态</th>
<th>操作</th>
</tr>
{% for p in active_round.positions|reverse %}
<tr>
<td><strong>{{ p.opened_at }}</strong></td>
<td>{{ p.open_price }}</td>
<td>{{ p.amp_threshold }}</td>
<td style="color:var(--danger-fg);font-weight:600;">{{ p.lock_price }}</td>
<td>{{ p.hands }}</td>
<td>
{% if p.locked_count > 0 %}
<span class="badge badge-warn">{{ p.locked_count }}/{{ p.hands }}</span>
{% else %}
<span class="na">0/{{ p.hands }}</span>
{% endif %}
</td>
<td>
{% if p.locked_count >= p.hands %}
<span class="badge badge-down">已全锁</span>
{% elif p.locked_count > 0 %}
<span class="badge badge-warn">部分锁仓</span>
{% else %}
<span class="badge badge-up">活跃</span>
{% endif %}
</td>
<td>
<div style="display:flex;gap:8px;justify-content:center;">
<form method="post" action="/positions/{{ p.id }}/lock" style="display:inline;">
<button style="background:var(--warn-bg);color:var(--warn-fg);border:1px solid var(--warn);padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.78rem;font-weight:600;"
{% if p.locked_count >= p.hands %}disabled style="opacity:0.4;cursor:default;"{% endif %}>
🔒 锁
</button>
</form>
<form method="post" action="/positions/{{ p.id }}/unlock" style="display:inline;">
<button style="background:var(--surface);color:var(--sub);border:1px solid var(--border);padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.78rem;"
{% if p.locked_count == 0 %}disabled style="opacity:0.4;cursor:default;"{% endif %}>
撤销
</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</table>
</div>
{% else %}
<div style="text-align:center;padding:40px;color:var(--sub);">暂无仓位,请添加当日仓位。</div>
{% endif %}
{# ═══════════════ 结束轮次 ═══════════════ #}
<div style="margin-top:28px;padding:20px;background:var(--surface);border:1px solid var(--border);border-radius:10px;display:flex;align-items:center;gap:16px;">
<span style="font-size:0.88rem;font-weight:600;">结束本轮:</span>
<form method="post" action="/positions/close" style="display:inline;">
<input type="hidden" name="result" value="profit_taken">
<button type="submit" class="btn" style="background:var(--success);color:#fff;" onclick="return confirm('确认止盈清仓?')">🟢 止盈</button>
</form>
<form method="post" action="/positions/close" style="display:inline;">
<input type="hidden" name="result" value="meltdown">
<button type="submit" class="btn" style="background:var(--danger);color:#fff;" onclick="return confirm('确认熔断清仓?')">🔴 熔断</button>
</form>
</div>
{% endif %}
<script>
var openPrice = document.getElementById('openPrice');
var ampThreshold = document.getElementById('ampThreshold');
var preview = document.getElementById('lockPricePreview');
function updatePreview() {
var op = parseInt(openPrice.value) || 0;
var at = parseInt(ampThreshold.value) || 0;
if (op > 0 && at > 0) {
preview.textContent = (op + at) + ' 点';
} else {
preview.textContent = '—';
}
}
if (openPrice && ampThreshold) {
openPrice.addEventListener('input', updatePreview);
ampThreshold.addEventListener('input', updatePreview);
}
</script>
{% endblock %}
BIN
View File
Binary file not shown.