diff --git a/ft-app/app/main.py b/ft-app/app/main.py
index b88e732..920c843 100644
--- a/ft-app/app/main.py
+++ b/ft-app/app/main.py
@@ -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, positions, trades, option_trades
+from app.routers import contracts, admin, auth, positions, trades, option_trades, dual_options
TEMPLATES_DIR = Path(__file__).parent / "templates"
@@ -59,6 +59,7 @@ app.include_router(admin.router)
app.include_router(positions.router)
app.include_router(trades.router)
app.include_router(option_trades.router)
+app.include_router(dual_options.router)
@app.get("/")
diff --git a/ft-app/app/models.py b/ft-app/app/models.py
index 92b0492..3808733 100644
--- a/ft-app/app/models.py
+++ b/ft-app/app/models.py
@@ -179,4 +179,54 @@ class OptionTrade(Base):
result = (self.open_price - self.close_price) * mul - (self.open_fee or 0) - (self.close_fee or 0)
else:
result = (self.close_price - self.open_price) * mul - (self.open_fee or 0) - (self.close_fee or 0)
- return round(result, 2)
\ No newline at end of file
+ return round(result, 2)
+
+
+class DualOptionTrade(Base):
+ __tablename__ = "dual_option_trades"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ product_code: Mapped[str] = mapped_column(String(10))
+ contract_code: Mapped[str] = mapped_column(String(10), index=True)
+ strike_price: Mapped[float] = mapped_column(Float)
+ call_open_price: Mapped[float] = mapped_column(Float)
+ put_open_price: Mapped[float] = mapped_column(Float)
+ call_open_fee: Mapped[float | None] = mapped_column(Float, nullable=True, default=0)
+ put_open_fee: Mapped[float | None] = mapped_column(Float, nullable=True, default=0)
+ open_date: Mapped[date] = mapped_column(Date)
+ point_value: Mapped[int] = mapped_column(Integer, default=20)
+ close_date: Mapped[date | None] = mapped_column(Date, nullable=True)
+ call_close_price: Mapped[float | None] = mapped_column(Float, nullable=True)
+ put_close_price: Mapped[float | None] = mapped_column(Float, nullable=True)
+ call_close_fee: Mapped[float | None] = mapped_column(Float, nullable=True, default=0)
+ put_close_fee: Mapped[float | None] = mapped_column(Float, nullable=True, default=0)
+ status: Mapped[str] = mapped_column(String(10), default="open")
+
+ @property
+ def pnl(self) -> float | None:
+ if self.call_close_price is None or self.put_close_price is None:
+ return None
+ mul = self.point_value
+ call_pnl = (self.call_close_price - self.call_open_price) * mul - (self.call_open_fee or 0) - (self.call_close_fee or 0)
+ put_pnl = (self.put_close_price - self.put_open_price) * mul - (self.put_open_fee or 0) - (self.put_close_fee or 0)
+ return round(call_pnl + put_pnl, 2)
+
+ @property
+ def total_premium(self) -> float:
+ return self.call_open_price + self.put_open_price
+
+ @property
+ def total_open_fee(self) -> float:
+ return (self.call_open_fee or 0) + (self.put_open_fee or 0)
+
+ @property
+ def total_cost(self) -> float:
+ return self.total_premium * self.point_value + self.total_open_fee
+
+ @property
+ def breakeven_upper(self) -> float:
+ return self.strike_price + self.total_premium
+
+ @property
+ def breakeven_lower(self) -> float:
+ return self.strike_price - self.total_premium
\ No newline at end of file
diff --git a/ft-app/app/routers/dual_options.py b/ft-app/app/routers/dual_options.py
new file mode 100644
index 0000000..073b4ee
--- /dev/null
+++ b/ft-app/app/routers/dual_options.py
@@ -0,0 +1,163 @@
+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 DualOptionTrade, Contract, Product
+
+router = APIRouter(prefix="/dual-options", tags=["dual_options"])
+
+
+def get_product_contracts(db: Session) -> dict:
+ result: dict[str, list[str]] = {}
+ products = db.query(Product).order_by(Product.code).all()
+ for p in products:
+ codes = [c.code for c in p.contracts if c.is_active]
+ if codes:
+ result[p.code] = codes
+ return result
+
+
+def today_str() -> str:
+ return date.today().isoformat()
+
+
+@router.get("/", response_class=HTMLResponse)
+def dual_option_page(request: Request, db: Session = Depends(get_db)):
+ view = request.query_params.get("view", "")
+ open_trades = (
+ db.query(DualOptionTrade).filter(DualOptionTrade.status == "open")
+ .order_by(DualOptionTrade.open_date.desc()).all()
+ )
+ closed_trades = (
+ db.query(DualOptionTrade).filter(DualOptionTrade.status == "closed")
+ .order_by(DualOptionTrade.close_date.desc()).limit(50).all()
+ )
+
+ template = request.app.state.templates.get_template("dual_options.html")
+ return HTMLResponse(
+ template.render(
+ request=request,
+ active_nav="dual_options",
+ product_contracts=get_product_contracts(db),
+ open_trades=open_trades,
+ closed_trades=closed_trades,
+ today=today_str(),
+ weekdays=["周一","周二","周三","周四","周五","周六","周日"],
+ )
+ )
+
+
+@router.post("/open")
+def open_trade(
+ request: Request,
+ contract_code: str = Form(...),
+ strike_price: float = Form(...),
+ call_open_price: float = Form(...),
+ put_open_price: float = Form(...),
+ call_open_fee: float = Form(0.0),
+ put_open_fee: float = Form(0.0),
+ open_date: str = Form(...),
+ db: Session = Depends(get_db),
+):
+ code = contract_code.upper()
+ contract = db.query(Contract).filter(Contract.code == code).first()
+ if contract:
+ product = contract.product
+ else:
+ product = db.query(Product).filter(Product.code == code[:2]).first()
+ product_code = product.code if product else code[:2]
+ point_value = product.point_value if product else 20
+
+ t = DualOptionTrade(
+ product_code=product_code,
+ contract_code=code,
+ strike_price=strike_price,
+ call_open_price=call_open_price,
+ put_open_price=put_open_price,
+ call_open_fee=call_open_fee,
+ put_open_fee=put_open_fee,
+ open_date=date.fromisoformat(open_date),
+ point_value=point_value,
+ status="open",
+ )
+ db.add(t)
+ db.commit()
+ return RedirectResponse("/dual-options/", status_code=303)
+
+
+@router.post("/{trade_id}/close")
+def close_trade(
+ request: Request,
+ trade_id: int,
+ close_date: str = Form(...),
+ call_close_price: float = Form(...),
+ put_close_price: float = Form(...),
+ call_close_fee: float = Form(0.0),
+ put_close_fee: float = Form(0.0),
+ db: Session = Depends(get_db),
+):
+ t = db.query(DualOptionTrade).filter(DualOptionTrade.id == trade_id).first()
+ if t and t.status == "open":
+ t.close_date = date.fromisoformat(close_date)
+ t.call_close_price = call_close_price
+ t.put_close_price = put_close_price
+ t.call_close_fee = call_close_fee
+ t.put_close_fee = put_close_fee
+ t.status = "closed"
+ db.commit()
+ return RedirectResponse("/dual-options/", status_code=303)
+
+
+@router.post("/{trade_id}/edit")
+def edit_trade(
+ request: Request,
+ trade_id: int,
+ contract_code: str = Form(...),
+ strike_price: float = Form(...),
+ call_open_price: float = Form(...),
+ put_open_price: float = Form(...),
+ call_open_fee: float = Form(0.0),
+ put_open_fee: float = Form(0.0),
+ open_date: str = Form(...),
+ close_date: str = Form(""),
+ call_close_price: str = Form(""),
+ put_close_price: str = Form(""),
+ call_close_fee: str = Form(""),
+ put_close_fee: str = Form(""),
+ db: Session = Depends(get_db),
+):
+ t = db.query(DualOptionTrade).filter(DualOptionTrade.id == trade_id).first()
+ if t:
+ code = contract_code.upper()
+ contract = db.query(Contract).filter(Contract.code == code).first()
+ if contract:
+ product = contract.product
+ else:
+ product = db.query(Product).filter(Product.code == code[:2]).first()
+ t.product_code = product.code if product else code[:2]
+ t.point_value = product.point_value if product else 20
+ t.contract_code = code
+ t.strike_price = strike_price
+ t.call_open_price = call_open_price
+ t.put_open_price = put_open_price
+ t.call_open_fee = call_open_fee
+ t.put_open_fee = put_open_fee
+ t.open_date = date.fromisoformat(open_date)
+ if close_date and call_close_price and put_close_price:
+ t.close_date = date.fromisoformat(close_date)
+ t.call_close_price = float(call_close_price)
+ t.put_close_price = float(put_close_price)
+ t.call_close_fee = float(call_close_fee) if call_close_fee else 0.0
+ t.put_close_fee = float(put_close_fee) if put_close_fee else 0.0
+ db.commit()
+ return RedirectResponse("/dual-options/", status_code=303)
+
+
+@router.post("/{trade_id}/delete")
+def delete_trade(trade_id: int, db: Session = Depends(get_db)):
+ t = db.query(DualOptionTrade).filter(DualOptionTrade.id == trade_id).first()
+ if t:
+ db.delete(t)
+ db.commit()
+ return RedirectResponse("/dual-options/", status_code=303)
diff --git a/ft-app/app/templates/base.html b/ft-app/app/templates/base.html
index 60c0ea7..401c481 100644
--- a/ft-app/app/templates/base.html
+++ b/ft-app/app/templates/base.html
@@ -212,6 +212,9 @@
📊 期权交易
+
+ 🎯 期权双买
+
⚙️ 系统管理
diff --git a/ft-app/app/templates/dual_options.html b/ft-app/app/templates/dual_options.html
new file mode 100644
index 0000000..f6c8404
--- /dev/null
+++ b/ft-app/app/templates/dual_options.html
@@ -0,0 +1,419 @@
+{% extends "base.html" %}
+{% block title %}期权双买交易{% endblock %}
+{% block heading %}期权双买交易{% endblock %}
+{% block breadcrumb %}跨式策略{% endblock %}
+{% block content %}
+
+{% set view = request.query_params.get('view', '') %}
+
+
+
+
+
+{% if view != 'closed' %}
+
+
+{# ── 新建双买开仓 ── #}
+
新建双买开仓(同时买入看涨 + 看跌)
+
+
+{# ── 持仓列表 ── #}
+
持仓中 · {{ open_trades|length }} 笔
+
+{% if open_trades %}
+
+
+ 品种 合约 行权价 开仓日期 看涨开仓价 看跌开仓价 C手续费 P手续费 总成本 操作
+ {% for t in open_trades %}
+
+ {{ t.product_code }}
+ {{ t.contract_code.replace(t.product_code, '', 1) }}
+ {{ t.strike_price }}
+ {{ t.open_date }} {{ weekdays[t.open_date.weekday()] }}
+ {{ t.call_open_price }}
+ {{ t.put_open_price }}
+ {{ t.call_open_fee or 0 }}
+ {{ t.put_open_fee or 0 }}
+ {{ "%.0f"|format(t.total_cost) }}
+
+ 平仓
+ 编辑
+
+
+
+ {% endfor %}
+
+
+{% else %}
+
暂无持仓
+{% endif %}
+
+{# ── 平仓弹窗 ── #}
+
+
+
+
+{% else %}
+
+{# ═══════════════ 已平仓 ═══════════════ #}
+
+{% if closed_trades %}
+
已平仓 · {{ closed_trades|length }} 笔
+
+
+ 品种 合约 行权价 开仓日 平仓日 持仓 看涨(开/平) 看跌(开/平) 总成本 盈亏 操作
+ {% for t in closed_trades %}
+
+ {{ t.product_code }}
+ {{ t.contract_code.replace(t.product_code, '', 1) }}
+ {{ t.strike_price }}
+ {{ t.open_date }} {{ weekdays[t.open_date.weekday()] }}
+ {{ t.close_date }} {{ weekdays[t.close_date.weekday()] }}
+ {{ (t.close_date - t.open_date).days }}天
+ {{ t.call_open_price }} / {{ t.call_close_price }}
+ {{ t.put_open_price }} / {{ t.put_close_price }}
+ {{ "%.0f"|format(t.total_cost) }}
+
+ {% set p = t.pnl %}
+ {% if p is not none %}
+
+ {% if p > 0 %}+{% endif %}{{ p }}
+
+ ⓘ
+ {% endif %}
+
+
+ 编辑
+
+
+
+ {% endfor %}
+
+
+
+{% set ns = namespace(total_pnl=0, total_open_fee=0, total_close_fee=0) %}
+{% for t in closed_trades %}
+ {% set ns.total_pnl = ns.total_pnl + (t.pnl or 0) %}
+ {% set ns.total_open_fee = ns.total_open_fee + (t.call_open_fee or 0) + (t.put_open_fee or 0) %}
+ {% set ns.total_close_fee = ns.total_close_fee + (t.call_close_fee or 0) + (t.put_close_fee or 0) %}
+{% endfor %}
+
+ 盈亏合计 {% if ns.total_pnl > 0 %}+{% endif %}{{ '%.2f'|format(ns.total_pnl) }}
+ |
+ 开仓手续费 {{ '%.2f'|format(ns.total_open_fee) }}
+ |
+ 平仓手续费 {{ '%.2f'|format(ns.total_close_fee) }}
+ |
+ 手续费合计 {{ '%.2f'|format(ns.total_open_fee + ns.total_close_fee) }}
+
+{% else %}
+
暂无已平仓记录
+{% endif %}
+
+
+{% endif %}
+
+{# ── 盈亏分析抽屉 ── #}
+
+
+
+{% if view != 'closed' %}
+
+{% endif %}
+
+{# ── 编辑弹窗 ── #}
+
+
+
+{# ── 通用确认弹窗 ── #}
+
+
+
+
+
+{% endblock %}
diff --git a/ft-app/app/templates/options.html b/ft-app/app/templates/options.html
index d9e03e0..5c9d064 100644
--- a/ft-app/app/templates/options.html
+++ b/ft-app/app/templates/options.html
@@ -69,7 +69,7 @@
- 权利金
+ 开仓价