Compare commits

..

77 Commits

Author SHA1 Message Date
fish 05d0b7524f 更新数据库文件
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-27 17:39:13 +08:00
fish 8ed87a23e9 更新数据库文件
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-27 17:15:34 +08:00
fish c9a7dee7ff 持仓全部视图卡片标题恢复分割线
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-27 15:11:46 +08:00
fish 794ca61358 持仓全部视图卡片标题间距优化
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-27 15:11:07 +08:00
fish 11aff71415 更新数据库文件
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-27 15:09:09 +08:00
fish e1dc4595a2 持仓列表增加品种子tab切换
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-27 15:08:34 +08:00
fish fa763bd968 更新数据库文件
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-27 15:03:12 +08:00
fish 9bce381497 已平仓盈亏颜色改为红涨绿跌
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-27 15:01:25 +08:00
fish 227d8b515f 对冲管理轮次移除日期显示
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-27 14:59:33 +08:00
fish c40b4b919c 更新数据库文件
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-27 13:00:31 +08:00
fish 434acb0470 锁仓上限改为可配置,默认3次不变,止盈按钮左侧增加修改入口
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-27 12:57:34 +08:00
fish 1a70a60dba 新增玻璃期货三合约综合持仓成交量分析,整理分析文件按日期归档
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-27 11:01:55 +08:00
fish 5cacb62d4c 新增 FG2609/FG2611/FG2701 席位资金分析报告
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-27 10:55:34 +08:00
fish d5648e0b2c 优化复制提示为居中弹窗并增加蒙层
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-27 10:44:20 +08:00
fish c291f331fc 新增持仓分析 prompt 生成功能
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-27 10:43:05 +08:00
fish 1faeff4ee9 更新数据库文件
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-27 10:11:16 +08:00
fish eb096d915d 锁仓操作增加锁仓价位确认弹窗
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-27 10:09:35 +08:00
fish abc436514a 修复编辑交易记录时手续费字段报错的问题
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-27 09:55:17 +08:00
fish b67dde0427 删除玻璃期货行情分析框架文档
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-26 21:33:16 +08:00
fish 4abec850a3 更新数据库文件
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-26 15:47:52 +08:00
fish 6c54a8ae95 持仓排名多空增加增减合计行
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-26 15:06:33 +08:00
fish b421945b16 更新数据库文件
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-26 14:58:41 +08:00
fish 6eb312451b 持仓排名增减配色改为红增绿减
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-26 14:58:27 +08:00
fish 9c00e167f6 修复合约管理Tab文字下划线
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-26 14:56:41 +08:00
fish 5ca5df72b6 合约管理改为Tab切换品种展示
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-26 14:54:52 +08:00
fish 06f7fc930a 更新数据库文件
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-26 14:27:05 +08:00
fish 1e6589a39f 修复页面标题被confirm弹窗代码污染
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-26 14:26:41 +08:00
fish 7bc85b2528 按钮样式:编辑绿底、删除红框无底
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-26 14:22:47 +08:00
fish 9bab06c1e7 删除按钮改为红框红字样式
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-26 14:14:45 +08:00
fish fb5cc801ee 删除确认改用自定义居中弹窗
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-26 14:13:01 +08:00
fish d87bb00208 已平仓列表新增持仓天数
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-26 14:10:48 +08:00
fish 0684f15859 日期字段添加周几显示
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-26 14:09:20 +08:00
fish c2a6b99fbd 按钮样式互换并统一间距
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-26 14:06:00 +08:00
fish c05a1b6c7f 编辑按钮加边框,操作按钮间距统一
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-26 13:58:52 +08:00
fish 8b5fa347cb 交易记录和期权新增编辑功能
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-26 13:56:40 +08:00
fish b22123bdfc 涨跌配色调整为红涨绿跌
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-26 13:51:47 +08:00
fish 8076cbbd51 期权新增买卖方向和盈亏算法展示
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-26 13:49:54 +08:00
fish c8ed098bfc 新增期权交易记录功能,支持C/P类型和行权价
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-26 13:32:21 +08:00
fish 71ef310648 更新数据库文件
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-26 11:38:34 +08:00
fish e3f288083c 对冲管理新增品种筛选,合约下拉去掉品种前缀
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-26 11:37:40 +08:00
fish d44e93dd4f 交易记录新建开仓改为先选品种再选合约
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-26 11:34:36 +08:00
fish 4db07bea59 更新数据库文件
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-26 11:28:35 +08:00
fish befcfcd282 仓位管理改名为对冲管理
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-26 11:28:27 +08:00
fish 85733e870c 行情数据改为顶部Tab切换品种查看行情
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-26 11:21:56 +08:00
fish 7a5795e131 更新数据库文件
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-26 11:12:37 +08:00
fish 46654a05b1 交易记录Tab增加icon图标
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-26 11:12:29 +08:00
fish 085652c2dd 交易记录改为顶部Tab切换,统一系统管理样式
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-26 11:11:42 +08:00
fish fb4710b011 修复交易记录导航折叠功能缺失的JS函数
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-26 11:05:10 +08:00
fish 5dd58b8297 交易记录拆分为持仓和已平仓两个子页面
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-26 11:03:45 +08:00
fish 4724741fdd 交易记录优化:品种合约分离展示、盈亏取整、手续费统计
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-26 10:58:33 +08:00
fish 9b08bd8bfd 交易记录价位和手续费支持小数输入
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-26 10:48:25 +08:00
fish adec664c52 新增交易记录功能,支持开平仓和盈亏计算
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-26 10:46:27 +08:00
fish e222a93204 仓位管理新增删除轮次功能
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-26 10:37:49 +08:00
fish 51be04e5d2 仓位管理支持多合约同时开轮
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-26 10:34:25 +08:00
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
fish 5670e5138c 合约列表新增次日振幅字段
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-26 10:08:37 +08:00
fish 5e9d9f00e8 更新数据库文件
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-26 10:02:16 +08:00
fish e155682b5f 合约列表按品种分节点展示
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-26 10:02:09 +08:00
fish 91840ca2ca 分析框架新增家人们席位情绪分析,数据库路径改为相对路径
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-25 23:36:12 +08:00
fish d000fce423 新增玻璃期货行情分析框架文档
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-25 23:26:43 +08:00
fish ac4236b03f 持仓排名数据清洗,机构名称为空时用名次兜底
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-25 23:16:42 +08:00
fish 223001d8a2 更新数据库文件
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-25 22:27:03 +08:00
fish 3a32429a5f 网络请求加超时防止卡死,持仓同步限制最近20天,次日预测移出表格
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-25 22:23:59 +08:00
fish 18575d1c55 已有同步任务时直接显示进度条,不再弹阻塞提示
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-25 21:54:55 +08:00
fish d62304b2c4 数据同步页增加持仓天数展示,修复持仓同步无法拉取历史数据的问题
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-25 21:52:32 +08:00
fish 9fd0116dec 后台同步任务超时后可覆盖启动,前端阻塞时显示提示而非静默隐藏
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-25 21:40:11 +08:00
fish ab9b132c69 更新数据库文件
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-25 21:34:34 +08:00
fish 1b0f906640 后台同步异常时前端显示错误提示,不再卡住页面
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-25 21:34:04 +08:00
fish 012b220c60 同步完成后自动刷新页面更新数据条数,修复持仓同步未提交事务,加大按钮间距
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-25 14:47:54 +08:00
fish 19203c050c 将SQLite数据库加入版本控制,方便换设备继续开发
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-25 14:41:35 +08:00
fish 58327a9a66 同步进度增加详细天数反馈,优化日期范围避免重复请求无数据的历史日期
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-25 14:03:33 +08:00
fish ca98967af0 移除全部合约同步按钮,改为单合约后台同步带进度条
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-25 13:53:44 +08:00
fish b74dccd805 持仓同步改为后台执行,添加进度条实时反馈
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-25 00:10:17 +08:00
fish cd7f6668af 修复数据采集和清理的bug,新增持仓排名功能,日线数据分页
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-25 00:01:41 +08:00
fish 5453ce1dcf 简化行情数据页面,修复同步数据无法补全历史的问题 2026-07-24 23:12:46 +08:00
fish c0e338eb23 移除博弈分析功能 2026-07-24 23:05:54 +08:00
31 changed files with 3284 additions and 578 deletions
+2 -1
View File
@@ -1,3 +1,4 @@
data/
data/*
!data/ft.db
__pycache__/
*.pyc
+1 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.11-slim
FROM python:3.13.11-slim
WORKDIR /app
@@ -0,0 +1,126 @@
# FG2609 席位资金分析报告(2026-07-20 → 2026-07-24
---
## 席位分类
| 分类 | 席位 |
|------|------|
| 家人(散户) | 东方财富、徽商期货、方正中期、中信建投、平安期货 |
| 大户(产业/套保) | 永安期货、中信期货、国泰君安、海通期货、华泰期货、高盛期货、乾坤期货、摩根大通、中粮期货 |
| 噪音(忽略) | 东证期货 |
| 其他 | 未归入以上三类的席位 |
---
## 一、家人席位动向(散户情绪)
| 日期 | 多单增减 | 空单增减 | 当日净多 |
|------|---------|---------|----------|
| 07-20 | -0.66万 | +0.75万 | -1.41万 |
| 07-21 | -3.38万 | -0.04万 | -3.34万 |
| 07-22 | -1.60万 | +0.50万 | -2.10万 |
| 07-23 | +0.83万 | -0.44万 | +1.27万 |
| 07-24 | -1.89万 | -0.30万 | -1.59万 |
**核心结论:散户持续看空,减多为主。**
- 5 日累计减多 **-6.69 万**,累计加空 **+0.46 万**,净多变化 **-7.15 万**。
- 唯一一天净多(07-23)量级很小(+1.27 万),第二天立刻转回净空,说明散户整体偏空且缺乏持续性信心。
- 07-21 单日减多 **-3.38 万**,是当周家人席位减多最大的一天。这一天跳空低开、放量下杀,散户在恐慌中止损多单——典型的追跌杀多行为。
- 家人席位里,方正中期、中信建投、徽商期货、东方财富、平安期货五个席位多单在 5 日内全部下降,空单方向仅中信建投在 07-20 和 07-22 有加空动作。
**定性:散户情绪整体偏空,在下跌中减多出逃,属于典型的情绪化交易。**
---
## 二、大户席位动向(产业/套保资金)
| 日期 | 多单增减 | 空单增减 | 当日净多 |
|------|---------|---------|----------|
| 07-20 | +0.13万 | -1.26万 | +1.39万 |
| 07-21 | +2.14万 | -1.93万 | +4.07万 |
| 07-22 | -2.10万 | -6.67万 | +4.57万 |
| 07-23 | -0.52万 | -1.56万 | +1.04万 |
| 07-24 | -0.29万 | -1.50万 | +1.21万 |
**核心结论:大户持续大规模减空,是本周最重要的资金信号。**
- 5 日累计减空 **-12.93 万**,减多仅 **-0.65 万**,净多变化 **+12.28 万**。
- 每天都是净多方向(大户每一天的空单减持量都大于多单减持量),全周无一天例外。
- **07-22(周三)是标志性节点**:大户单日减空 **-6.67 万**,是当周最大单日空头撤离。当天高盛减空 -1.49 万、中信减空 -2.20 万、国泰君安减空 -0.93 万,三家头部大户同步砍空单。
### 关键大户席位拆解
**空头三巨头(中信、高盛、国泰君安):**
| 席位 | 07-20 空单 | 07-24 空单 | 5日减空 |
|------|-----------|-----------|---------|
| 中信期货 | 21.61万 | 18.07万 | **-3.54万** |
| 高盛期货 | 21.93万 | 17.81万 | **-4.12万** |
| 国泰君安 | 18.81万 | 17.47万 | **-1.34万** |
三家合计减空 **9.00 万**,占了全部大户减空量的 70%。这不是散户式的小打小闹,是主力资金在系统性地撤退空头头寸。
**定性:大户资金明确偏多。产业/套保盘的空头大幅回补,说明产业端认为当前价位已不具备继续做空的安全边际,或是交割月临近主动平空离场。**
---
## 三、多空力量对比
| 分类 | 5日多单累计 | 5日空单累计 | 净多(多-空)| 方向 |
|------|------------|------------|-------------|------|
| 家人 | -6.69万 | +0.46万 | **-7.15万** | 偏空 |
| 大户 | -0.65万 | -12.93万 | **+12.28万** | 强烈偏多 |
| 其他 | -4.24万 | -3.63万 | **-0.62万** | 略偏空 |
| **合计** | -11.58万 | -16.10万 | **+4.52万** | 偏多 |
**核心结论:大户的净多力量(+12.28 万)远超家人(-7.15 万)和其他席位(-0.62 万)的净空力量之和。**
- 全市场合计净多 **+4.52 万**,由大户独力支撑。
- 家人与其他席位合计净空 **-7.77 万**,与大户形成明确对手盘——大户在接散户抛出的多单、同时平掉自己的空单,典型的"聪明钱吃傻钱"格局。
- 东证期货被定义为噪音席位已排除,不影响结论。
---
## 四、异常信号
### 1. 07-22(周三)空头集体溃退
大户单日减空 **-6.67 万**,是 5 日均值(-2.59 万)的 2.6 倍。中信 -2.20 万、高盛 -1.49 万、国泰 -0.93 万同时大幅减空。当天成交量 Top 1 的国泰君安成交达 38.13 万手(本周最高),放量明显。这一天出现了明确的空头踩踏信号。
### 2. 07-21(周二)散户与大户严重分歧
家人减多 **-3.38 万**(全周最大单日减多),大户加多 **+2.14 万**(全周最大单日加多)。一方恐慌出逃,一方逆势加仓。这是本周最典型的"对手盘"交易日。如果当天价格下跌,则大户在低位吸筹;如果价格上涨,则散户在反弹中解套离场——无论哪种,都是大户占优。
### 3. 冠通期货(其他席位)异动
07-22 成交 +4.23 万(从名不见经传到跻身 Top 13),07-23 再度 +4.78 万成交并进入 Top 6。这个席位两天成交暴增近 10 万手,背后可能有大资金借道入场,值得后续关注。
### 4. 永安期货多单逆势增加
永安 5 日多单从 4.38 万 → 4.53 万 → 4.56 万 → 4.99 万 → 5.19 万,累计加多 **+0.81 万**。同时空单从 6.41 万减至 4.62 万(-1.79 万)。永安的"多增空减"在大户中最为突出,是本周方向最明确的偏多席位。
---
## 五、行情预判
### 判断:偏多,短期看反弹
**理由:**
1. **大户资金面强烈偏多**:5 日净多 +12.28 万,头部空头(中信、高盛、国泰)合计减空 9 万手,空头回补动能远未耗尽。如果这些席位还有空头敞口,下周可能继续平空,形成持续买盘。
2. **散户是反向指标**:家人席位 5 日净空 -7.15 万,在下跌中恐慌减多,符合散户在底部区域的行为特征。散户出清往往是阶段性底部的必要条件。
3. **对手盘格局明显**:大户接散户的盘,且大户是主动方(减空 + 稳多),散户是被动方(减多),这是典型的筑底资金结构。
4. **永安、国泰君安等头部席位方向一致**:多家大户多单稳定、空单持续下降,资金面没有分歧。
### 风险提示
- 全周减多总量(-11.58 万)也很大,说明多头信心整体不足。大户更多是"不空了"而非"大力做多",反弹力度取决于后续是否有新增多单入场。
- 冠通期货的异常放量可能代表新的空头力量进场,需要观察其后续持仓方向。
- 如果下一交易日价格反弹后大户开始减多,则说明这只是一轮空头回补行情,而非趋势反转。
**综合判断:偏多(概率 65%),预计 FG2609 下一交易日震荡偏强,关注空头回补持续性和是否有增量多头入场。**
@@ -0,0 +1,133 @@
# FG2611 席位资金分析报告(2026-07-20 → 2026-07-24
---
## 席位分类
| 分类 | 席位 |
|------|------|
| 家人(散户) | 东方财富、徽商期货、方正中期、中信建投、平安期货 |
| 大户(产业/套保) | 永安期货、中信期货、国泰君安、海通期货、华泰期货、高盛期货、乾坤期货、摩根大通、中粮期货 |
| 噪音(忽略) | 东证期货 |
| 其他 | 未归入以上三类的席位 |
---
## 一、家人席位动向(散户情绪)
| 日期 | 多单增减 | 空单增减 | 当日净多 |
|------|---------|---------|----------|
| 07-20 | -0.06万 | -0.15万 | +0.09万 |
| 07-21 | -0.00万 | +0.01万 | -0.01万 |
| 07-22 | +0.07万 | -0.04万 | +0.11万 |
| 07-23 | +0.02万 | -0.06万 | +0.08万 |
| 07-24 | -0.13万 | -0.06万 | -0.07万 |
**核心结论:家人席位参与度极低,无明确方向性。**
- 5 日累计减多 **-0.10 万**,累计减空 **-0.30 万**,净多变化 **+0.20 万**。
- 每日变动量级在数百手级别,与 FG2609 主连合约上万手的波动完全不同。FG2611 是远月合约,散户参与度天然较低。
- 无追涨杀跌迹象——单日最大变动仅 -0.13 万(07-24 减多),远不足以构成情绪判断。
- 家人持仓绝对量也极小:中信建投是唯一多空持仓均超 2 万的家人席位,东方财富多单仅 0.32 万、空单未进 Top 20。
**定性:远月合约散户参与度极低,家人席位资金信号不具备参考价值。**
---
## 二、大户席位动向(产业/套保资金)
| 日期 | 多单增减 | 空单增减 | 当日净多 |
|------|---------|---------|----------|
| 07-20 | -0.68万 | -0.31万 | -0.37万 |
| 07-21 | -0.10万 | +0.14万 | -0.24万 |
| 07-22 | -0.28万 | +0.20万 | -0.48万 |
| 07-23 | +0.98万 | +0.42万 | +0.56万 |
| 07-24 | +0.41万 | +0.30万 | +0.11万 |
**核心结论:前三天减多偏空,后两天显著反转加多,方向切换明显。**
- 5 日累计加多 **+0.33 万**,加空 **+0.76 万**,净多变化 **-0.43 万**。总体略偏空,但主要是前三天贡献的。
- **07-23(周四)是转折点**:大户加多 +0.98 万,是 5 日最大单日加多。华泰期货加多 +0.74 万(从 0.18 万跳升至 0.92 万,增量惊人)、中粮期货加多 +0.32 万。
- 空头方面,高盛期货持续加空:从 07-20 的 1.05 万 → 07-21 的 1.20 万 → 07-22 的 1.76 万 → 07-23 的 1.91 万 → 07-24 的 2.01 万,5 日累计加空 **+0.96 万**,是大户中唯一持续单向加空的席位。
- 国泰君安 07-24 多单 +0.26 万、空单 +0.32 万,双边大幅加仓,方向不明确但参与度在提升。
### 关键大户席位拆解
**中粮期货——本周最活跃的大户席位:**
| 指标 | 07-20 | 07-21 | 07-22 | 07-23 | 07-24 |
|------|-------|-------|-------|-------|-------|
| 成交量 | 5.05万 | 2.81万 | 5.62万 | 7.48万 | 1.65万 |
| 多单 | 1.48万 | 1.16万 | 1.43万 | 1.75万 | 1.79万 |
| 空单 | 1.46万 | 1.14万 | 1.39万 | 1.73万 | 1.73万 |
中粮在 FG2611 上多空双向加仓,多单从 1.48 万增至 1.79 万(+0.31 万),空单从 1.46 万增至 1.73 万(+0.27 万)。**这是典型的套利/套保盘口——多空同步增加,而非单纯做方向。**
**华泰期货 07-23 异动:**
华泰多单从 0.18 万(07-22)暴增至 0.92 万(07-23),单日加多 **+0.74 万**,增幅超过 400%。同时成交量从 0.42 万放大至 1.22 万。这是一个非常突兀的增仓,值得重点关注后续是否持续。
**定性:大户整体略偏空(净多 -0.43 万),但后半周出现方向切换迹象。高盛是唯一坚定的空头加仓者,中粮以套利盘为主,华泰异动值得追踪。**
---
## 三、多空力量对比
| 分类 | 5日多单累计 | 5日空单累计 | 净多(多-空)| 方向 |
|------|------------|------------|-------------|------|
| 家人 | -0.10万 | -0.30万 | **+0.20万** | 微偏多 |
| 大户 | +0.33万 | +0.76万 | **-0.43万** | 略偏空 |
| 其他 | -0.72万 | -1.20万 | **+0.48万** | 偏多 |
| **合计** | -0.49万 | -0.74万 | **+0.25万** | 微偏多 |
**核心结论:FG2611 整体资金量级较小,市场方向不明确。**
- 全市场净多仅 **+0.25 万**,与 FG2609 的 +4.52 万形成鲜明对比。远月合约的资金博弈强度远低于主连。
- 三大分类之间无明显对手盘关系——家人偏多(+0.20 万)、大户偏空(-0.43 万)、其他偏多(+0.48 万),但量级都太小,无法形成有效信号。
- 其他席位(+0.48 万)贡献了最大的净多力量,但这些席位分散、方向一致性弱。
**定性:远月合约流动性差,各类资金均未展现出明确的战略方向,不宜据此做出方向判断。**
---
## 四、异常信号
### 1. 07-23(周四)大户方向逆转
前半周大户减多为主(三日累计 -1.06 万),07-23 突然加多 +0.98 万。华泰期货一天加多 +0.74 万是主要推手。这种突然的方向切换值得警惕——可能是某个产业户判断价格到位后集中建仓多单。
### 2. 高盛期货持续单向加空
高盛空单每日递增:1.05 → 1.20 → 1.76 → 1.91 → 2.01 万,5 日累计 +0.96 万,且多单从不在 Top 20。在所有大户席位中,高盛是唯一一个方向完全一致的席位——**只做空、不加多**。这在 FG2609 高盛大规模减空的背景下显得尤为突兀:主连上高盛在撤退,远月上高盛在加码。需要关注这是跨期套利(空 11 多 09)还是纯粹的远月看空。
### 3. 07-24(周五)中粮成交量骤降
中粮成交量从 07-23 的 7.48 万骤降至 07-24 的 1.65 万(-5.82 万),降幅居全席位之首。而当天中粮多空持仓几乎未变(多 +0.04 万、空 +0.00 万)。这说明中粮在周四大幅建仓后,周五选择锁仓不动,符合套保盘的交易特征。
### 4. 华泰期货多单暴增
07-23 华泰多单从 0.18 万跳升至 0.92 万(+0.74 万),而 07-22 华泰多单甚至不在 Top 20(意味持仓低于 0.27 万)。也就是说在两天内华泰从几乎零多单变成了近万手多单。这是一个需要持续追踪的信号——如果后续继续加仓,可能预示产业资金在远月布局。
---
## 五、行情预判
### 判断:震荡,方向不明
**理由:**
1. **资金量级太小**:全周净多仅 +0.25 万,各项资金变动均不足以支撑方向判断。远月合约天然流动性差,席位分析的有效性大幅下降。
2. **大户内部存在分歧**:高盛持续单向加空(+0.96 万),但华泰(+0.74 万)和中粮(+0.31 万)在加多。同为产业/套保大户,方向截然相反,说明产业端对远月定价存在明显分歧。
3. **与 FG2609 的方向背离**:FG2609 大户强烈偏多(净多 +12.28 万),而 FG2611 大户略偏空(净多 -0.43 万)。高盛在 2609 上减空 -4.12 万,在 2611 上加空 +0.96 万——这可能是跨期套利,而非单纯看多或看空。
4. **缺乏家人席位验证**:远月合约散户参与度极低,无法通过"家人—大户对手盘"框架来验证信号。缺少了一个重要的判断维度。
### 参考建议
- **以 FG2609 为主、FG2611 为辅**:主连合约资金博弈充分,信号更可靠。FG2611 当前阶段更适合作为跨期价差分析的参考,而非独立的方向判断依据。
- **重点关注华泰期货后续动向**:如果华泰在 FG2611 上继续加多,同时 FG2609 方向一致,则强化偏多判断。
- **关注高盛的跨期行为**:高盛在 2609 减空、2611 加空,如果这一模式持续,可能是"近强远弱"的跨期结构信号。
**综合判断:FG2611 自身资金面无法给出有效方向,建议以 FG2609 的分析结论为主(偏多),FG2611 跟随主连波动。远月合约暂以震荡看待。**
@@ -0,0 +1,166 @@
# FG2701 席位资金分析报告(2026-07-20 → 2026-07-24
---
## 席位分类
| 分类 | 席位 |
|------|------|
| 家人(散户) | 东方财富、徽商期货、方正中期、中信建投、平安期货 |
| 大户(产业/套保) | 永安期货、中信期货、国泰君安、海通期货、华泰期货、高盛期货、乾坤期货、摩根大通、中粮期货 |
| 噪音(忽略) | 东证期货 |
| 其他 | 未归入以上三类的席位 |
---
## 一、家人席位动向(散户情绪)
| 日期 | 多单增减 | 空单增减 | 当日净多 |
|------|---------|---------|----------|
| 07-20 | +0.08万 | +0.61万 | -0.53万 |
| 07-21 | +0.06万 | 0.00万 | +0.06万 |
| 07-22 | +0.20万 | +0.45万 | -0.25万 |
| 07-23 | +0.10万 | -0.22万 | +0.32万 |
| 07-24 | -0.02万 | -0.18万 | +0.16万 |
**核心结论:家人席位多空双增,但空单增幅更大,整体略偏空。**
- 5 日累计加多 **+0.43 万**,加空 **+0.65 万**,净多变化 **-0.23 万**。
- 前半周(07-20、07-22)偏空,后半周(07-23、07-24)偏多,方向摇摆不定。
- **07-20(周一)是家人加空最多的一天**:+0.61 万,全部来自中信建投单日加空 **+0.56 万**。中信建投空单从 0.80 万跳升至 1.50 万(07-22 最高点),后半周又回落到 1.07 万。这不是散户行为,更像是中信建投自营或大户借道这个席位做空了远月,因为散户不太可能一天加 5600 手空单。
- 方正中期是家人中多单最大的席位(1.03→1.03→1.08→1.03→1.06 万),5 日几乎未变,方向中性。
- 东方财富和平安期货持仓量极小(0.3 万级别),纯散户参与度极低。
**定性:FG2701 远月合约家人席位参与度低,且中信建投的"家人"分类在此合约上可能失真——其 07-20 单日加空 0.56 万的行为更像是大户级的操作。**
---
## 二、大户席位动向(产业/套保资金)
| 日期 | 多单增减 | 空单增减 | 当日净多 |
|------|---------|---------|----------|
| 07-20 | +0.15万 | +0.27万 | -0.12万 |
| 07-21 | -0.76万 | 0.00万 | -0.76万 |
| 07-22 | -0.25万 | -0.28万 | +0.03万 |
| 07-23 | -0.24万 | +0.68万 | -0.92万 |
| 07-24 | +0.12万 | -0.16万 | +0.28万 |
**核心结论:大户显著偏空,是三个合约中大户方向最明确的空头。**
- 5 日累计减多 **-0.97 万**,加空 **+0.51 万**,净多变化 **-1.48 万**。
- 5 天中有 3 天净空,且 07-21-0.76 万)和 07-23-0.92 万)两天的净空量级明显大于另外三天的波动幅度。
- 与 FG2609(大户净多 +12.28 万)和 FG2611(大户净多 -0.43 万)形成渐进式对比:**越远的合约,大户越偏空**。
### 关键大户席位拆解
**国泰君安——FG2701 的"超级空头"**
| 指标 | 07-20 | 07-21 | 07-22 | 07-23 | 07-24 |
|------|-------|-------|-------|-------|-------|
| 多单 | 1.40万 | 1.43万 | 1.50万 | 1.32万 | 1.39万 |
| 空单 | **4.42万** | — | 4.15万 | **4.46万** | **4.54万** |
国泰君安的空单(4.54 万)是它多单(1.39 万)的 **3.3 倍**,占大户空单总量的近 50%,占全市场空单的约 30%。在 FG2701 这个流动性远不如主连的远月合约上,单一席位持有如此庞大的空头敞口,非常不寻常。
这个席位在三个合约上的行为完全不同:
- FG2609:空单 18.81 → 17.47 万(减空 -1.34 万),**偏多**
- FG2611:多空双向,方向模糊
- FG2701:空单 4.42 → 4.54 万(加空 +0.12 万),**坚定偏空**
**这很可能不是同一类资金在操作三个合约**。FG2701 的国泰君安空头大概率是产业套保盘——玻璃企业锁定明年 1 月的卖出价格。
**高盛期货——锁仓不动:**
高盛空单全周维持在 1.70-1.71 万,几乎零变动。对比 FG2609 上高盛大幅减空 4.12 万、FG2611 上加空 0.96 万,FG2701 上的高盛选择了按兵不动。
**华泰期货多单持续缩水:**
华泰多单从 07-20 的 1.63 万 → 07-21 的 0.92 万(-0.71 万)→ 07-22/23/24 维持 0.87-0.91 万。华泰曾是 FG2701 上最大的多头,但 07-21 一天就砍掉了近一半的多单。这是一个明确的减多信号。
**永安期货多减空增:**
永安多单从 0.89 万 → 0.85 → 0.41 → 0.42 → 0.46 万(减多 -0.43 万),空单从 0.88 → 无 → 1.02 → 1.32 → 1.06 万(加空 +0.18 万)。方向明确偏空,与国泰君安形成呼应。
**定性:大户在 FG2701 上呈现一致的偏空布局。国泰君安的 4.54 万天量空单是核心信号,永安、华泰(减多方面)也指向同一方向。高盛虽然锁仓不动,但其在 FG2611 上的加空行为暗示整体偏空倾向。**
---
## 三、多空力量对比
| 分类 | 5日多单累计 | 5日空单累计 | 净多(多-空)| 方向 |
|------|------------|------------|-------------|------|
| 家人 | +0.43万 | +0.65万 | **-0.23万** | 略偏空 |
| 大户 | -0.97万 | +0.51万 | **-1.48万** | 明确偏空 |
| 其他 | +1.01万 | +1.77万 | **-0.75万** | 偏空 |
| **合计** | +0.47万 | +2.93万 | **-2.46万** | 明确偏空 |
**核心结论:FG2701 全市场三大分类一致偏空,是本周三个合约中方向最统一的空头合约。**
- 全市场净空 **-2.46 万**,与 FG2609 的 +4.52 万净多形成 180 度背离。
- 其他席位是最大的空头增量来源(+1.77 万空单),但分散在多个席位上,方向一致性不如大户。
- 没有人做多:大户减多(-0.97 万)而非加多,其他席位加多(+1.01 万)但加空更多(+1.77 万),家人加多(+0.43 万)但加空同样更多(+0.65 万)。
### 三大合约方向对比
| 合约 | 大户净多 | 总净多 | 方向 |
|------|---------|--------|------|
| FG2609(近月) | +12.28万 | +4.52万 | **强烈偏多** |
| FG2611(次月) | -0.43万 | +0.25万 | 震荡 |
| FG2701(远月) | -1.48万 | -2.46万 | **明确偏空** |
**这构成了一条清晰的期限结构:近强远弱,越远期越偏空。** 这符合商品期货在 contango/backwardation 结构下的常见特征——近月受现货支撑偏强,远月因产业套保盘(玻璃厂卖出远期产量)而承压。
**定性:FG2701 全市场一致偏空,核心驱动力是大户套保空头(国泰君安 4.54 万)和其他席位的分散加空。这并非市场情绪的"看空",更像是产业资金利用远月合约进行卖出套保的正常操作。**
---
## 四、异常信号
### 1. 国泰君安的"超级空头"是 FG2701 的定价锚
一个席位在远月合约上持有 4.54 万空单、多单仅 1.39 万,净空 3.15 万。这个量级在整个 FG2701 市场中占比极大。**如果国泰君安这个空头开始平仓(买回),会对价格形成巨大上推力。** 这是 FG2701 最大的潜在多空催化剂。
### 2. 07-24(周五)其他席位空单集中暴增
其他席位空单 07-24 单日 +1.33 万,是 5 日最大单日增幅。主要贡献者:
- 国投期货空单 +0.54 万(从 0.39 万跳至 0.93 万)
- 广发期货空单 +0.46 万(从 0.38 万跳至 0.84 万)
- 瑞达期货空单 +0.23 万(从 0.68 万增至 0.91 万)
三家合计加空 1.23 万。周五通常是减仓日(规避周末风险),但这三家反而在周五尾盘加空,值得关注。
### 3. 07-21(周二)华泰多单崩盘式减仓
华泰期货多单从 1.63 万暴跌至 0.92 万(-0.71 万),单日减幅 44%。当天华泰成交量 1.62 万(较前一日的 0.58 万放大近 3 倍),但多单大减、空单未变——说明是单向平多出逃。作为 FG2701 原本的第一大多头,华泰的突然撤退是重要的偏空信号。
### 4. 中信建投的异常空单操作(07-20 和 07-22
中信建投空单:07-20 为 0.80 万,07-20 当天 +0.56 万 → 07-22 继续增至 1.50 万 → 07-23 降至 1.32 万 → 07-24 再降至 1.07 万。先猛加后缓减,这种操作模式更像是机构行为而非散户行为。将其归入"家人"分类在 FG2701 上可能不准确。
---
## 五、行情预判
### 判断:偏空,但不宜追空(空头已高度拥挤)
**理由:**
1. **方向一致但过于拥挤**:全市场三大分类全部偏空,总净空 -2.46 万。国泰君安一家就净空 3.15 万。当一个方向上所有人都已入场,反向的边际力量反而更大——任何风吹草动都可能触发空头回补。
2. **产业套保逻辑清晰**:FG2701 对应明年 1 月交割,是玻璃消费淡季。玻璃厂在远月合约做卖出套保(锁定销售价格)是合理的产业行为。国泰君安的 4.54 万空单大概率是产业客户的套保盘,这类资金通常持有到期,不会频繁交易。所以这 4.54 万更像是"压舱石"而非"投机空头"。
3. **跨期结构已定价**:近强远弱(FG2609 强多 → FG2701 强空)的期限结构已经非常清晰。如果你已经做空 FG2701,这个结构已经 price in。真正的边际变化要看:
- 近月 FG2609 的空头回补能否带动远月跟涨
- 国泰君安是否会在 FG2701 上继续加空
4. **周五空头集中加仓可能是尾部信号**:07-24 其他席位集中加空 +1.33 万,如果这是新空头的"最后一波"入场,那么空头动能可能即将衰竭。
### 情景推演
| 情景 | 触发条件 | FG2701 走势 |
|------|---------|------------|
| 基准(60%) | FG2609 继续反弹,国泰君安维持空单 | 震荡偏弱,跟随主连但涨幅受限 |
| 空头回补(25%) | FG2609 大幅反弹,国泰君安开始平 FG2701 空单 | 涨幅可能超过 FG2609(空头踩踏) |
| 继续加空(15%) | 市场情绪恶化,产业端加大套保力度 | 独立走弱,跌幅大于 FG2609 |
**综合判断:偏空(概率 60%),但风险收益比已不适合追空。FG2701 的空头过于拥挤,一旦触发回补行情,反弹速度和幅度可能超预期。建议以观望为主,若持有空单则设好止盈,若空仓则等待更明确的信号。FG2609 的偏多判断和 FG2701 的偏空判断并不矛盾——它们反映的是同一品种不同到期月份的合理价差结构。**
@@ -0,0 +1,123 @@
# 玻璃期货综合持仓与成交量分析(2026-07-27 盘前)
基于 FG2609/FG2611/FG2701 三份席位分析报告,对下个交易日(07-27 周一)的行情推演。
---
## 一、三合约核心数据总览
| 指标 | FG2609(主连) | FG2611(次月) | FG2701(远月) |
|------|:------------:|:------------:|:------------:|
| 大户 5 日净多 | **+12.28 万** | -0.43 万 | -1.48 万 |
| 家人 5 日净多 | -7.15 万 | +0.20 万 | -0.23 万 |
| 全市场净多 | **+4.52 万** | +0.25 万 | **-2.46 万** |
| 方向 | **强烈偏多** | 震荡 | 明确偏空 |
| 主力信号 | 空头踩踏回补 | 流动性不足 | 产业套保压盘 |
**期限结构清晰:近强远弱,逐月递减。FG2609 是行情发动机,FG2611 跟随,FG2701 独立承压。**
---
## 二、交易量分析:放量集中在近月,空头回补是主要成交来源
**FG2609 成交量是核心。** 07-22(周三)国泰君安单日成交 38.13 万手,为全周最高,当天大户减空 -6.67 万手,中信 -2.20 万、高盛 -1.49 万、国泰 -0.93 万同步砍空。这是典型的**空头踩踏式放量**——主要成交不是新增多头在买,而是老空头在抢着平仓。
FG2611 的成交也佐证了"量在近月":中粮期货 07-23 成交 7.48 万手(周最高),07-24 骤降至 1.65 万手后锁仓不动,套保盘集中建仓后收手。
**FG2701 周五出现反常放量**——其他席位(国投 +0.54 万、广发 +0.46 万、瑞达 +0.23 万)合计加空 1.23 万手。周五通常是避险减仓日,此时加空说明有空头在押注周一继续下跌,这构成周一潜在的**空头拥挤风险**。
---
## 三、持仓结构分析:三合约构成跨期对手盘矩阵
**最核心的逻辑不在单个合约内部,而在三个合约之间的资金联动:**
### 高盛期货——跨期套利布局
| 合约 | 行为 | 5 日变动 |
|------|------|---------|
| FG2609 | **大幅减空** | -4.12 万 |
| FG2611 | **持续加空** | +0.96 万 |
| FG2701 | 锁仓不动 | ~0 |
高盛在做 **"多近月、空远月"的跨期套利**,不是单边看多或看空。它认为近月(09)被低估、远月(11)相对高估,近强远弱的价差会继续扩大。
### 国泰君安——三合约三个角色
| 合约 | 角色 | 持仓特征 |
|------|------|---------|
| FG2609 | 空头回补者 | 空单 18.81→17.47 万,减空 -1.34 万 |
| FG2611 | 双边加仓 | 多空同步增加,方向模糊 |
| FG2701 | **超级空头** | 空单 4.54 万,是多单的 3.3 倍 |
国泰君安在 FG2701 上 4.54 万空单占全市场约 30%,这大概率是产业套保盘(玻璃厂锁定明年 1 月卖出价格),持有到期、不频繁交易,是 FG2701 的长期压舱石。
### 永安期货——唯一的多空同向偏多席位
永安在 FG2609 上:多单 5 日递增(4.38→4.53→4.56→4.99→5.19 万,+0.81 万),空单递减(6.41→4.62 万,-1.79 万)。**多增空减,在所有大户中方向最干净、最统一。**
### 空头三巨头剩余敞口
| 席位 | 07-24 空单 | 5 日减空 | 剩余空头敞口 |
|------|-----------|---------|------------|
| 中信期货 | 18.07 万 | -3.54 万 | 仍很大 |
| 高盛期货 | 17.81 万 | -4.12 万 | 仍很大 |
| 国泰君安 | 17.47 万 | -1.34 万 | 仍很大 |
| **合计** | **53.35 万** | **-9.00 万** | — |
三家合计仍持有 53.35 万空单,上周只减了 9 万。**如果反弹延续,空头回补的买盘还远未耗尽。**
---
## 四、下个交易日(07-27)行情推演
### FG2609(主连):偏多(概率 65%),看反弹延续
**做多力量来源:**
1. **空头回补动能远未耗尽**:三大空头仍有 53.35 万敞口。反弹延续 → 空头继续砍仓 → 形成持续买盘的逻辑链条仍在。
2. **永安在持续加多**:唯一在价格低位连续 5 日递增多单的大户,方向最明确。
3. **散户是反向指标**:家人 5 日净空 -7.15 万,在下跌中止损多单。散户出清 + 大户接盘 = 典型的筑底资金结构。
**抑制上涨的因素:**
- 大户更多是"不空了"而非"大力做多"(减空 -12.93 万 vs 减多仅 -0.65 万)。空头回补驱动的反弹,一旦空头平完、没有新增多头接力,反弹就会熄火。
- 冠通期货连续两天放量(+9 万手),持仓方向未知,可能是新的空头力量入场。
**情景判断:**
| 情景 | 概率 | 条件 | FG2609 走势 |
|------|:---:|------|------------|
| 基准 | 65% | 空头回补延续,成交量维持 | 震荡偏强,小幅上行 |
| 回调 | 25% | 反弹到位,空头停止回补 | 冲高回落,转震荡 |
| 加速上涨 | 10% | 增量多头入场 + 空头踩踏 | 放量大涨 |
### FG2611(次月):跟随主连震荡,无独立行情
流动性太差,全周净多仅 +0.25 万。唯一关注点:**华泰期货 07-23 多单暴增 +0.74 万**——如果周一华泰继续加多,是偏多信号;如果减仓,说明只是一日游。
### FG2701(远月):偏空但有回补风险,不建议追空
- 三类资金一致偏空,总净空 -2.46 万,方向最统一。
- **但空头已高度拥挤**:国泰一家净空 3.15 万 + 周五新空头集中入场(+1.23 万)。当一个方向所有人都已上船,反向的边际冲击最大。
- **核心变量是 FG2609**:如果主连大幅反弹触发远月空头回补,FG2701 涨幅可能超 FG2609(空头踩踏)。国泰的 4.54 万套保盘不会动,但投机空头会跑得很快。
| 情景 | 概率 | 触发条件 | FG2701 走势 |
|------|:---:|---------|------------|
| 基准 | 60% | FG2609 小幅反弹,国泰维持空单 | 震荡偏弱,涨幅受限 |
| 空头回补 | 25% | FG2609 大幅反弹,投机空头平仓 | 涨幅可能超 FG2609 |
| 继续加空 | 15% | 市场情绪恶化,产业加大套保 | 独立走弱 |
---
## 五、综合结论
> **下个交易日整体判断:偏多震荡。FG2609 是核心战场,空头回补是主要驱动。FG2701 的空头拥挤是最大的潜在爆发点——如果主连大幅上涨触发远月空头回补,可能出现近远月同步拉升的极端行情。反之,如果主连反弹乏力、成交量萎缩,则空头回补行情接近尾声,谨慎追多。**
### 周一盘中关键观察指标
1. **FG2609 成交量**:放量 = 空头继续砍仓(偏多),缩量 = 回补动能衰竭(转震荡)
2. **中信/高盛/国泰空单变化**(盘后数据):继续下降则反弹延续
3. **FG2701 是否跟涨**:跟涨说明空头回补扩散;不跟涨是正常期限结构
4. **华泰 FG2611 多单**:继续增加则强化远月翻多信号
5. **冠通期货持仓方向**:如果其方向为多,则强化偏多;如果为空,则是新的压力来源
+265 -5
View File
@@ -1,14 +1,159 @@
"""Data collector — fetch OHLCV from akshare and upsert into daily_bars."""
import socket
import threading
import time
from datetime import date
socket.setdefaulttimeout(15)
from app.database import SessionLocal
from app.models import DailyBar, Contract
from app.engine.lock_strategy import compute_amp_5d
_progress = {"running": False, "label": "", "done": 0, "total": 0, "result": 0, "finished": False, "error": "", "started_at": 0}
_lock = threading.Lock()
STALE_SECONDS = 120
def get_progress() -> dict:
with _lock:
return dict(_progress)
def _start_bg(target, label):
with _lock:
now = time.time()
if _progress["running"] and (now - _progress["started_at"]) < STALE_SECONDS:
return False
_progress.update(running=True, finished=False, label=label, done=0, total=0, error="", started_at=now)
def _run():
try:
target()
except Exception as e:
with _lock:
_progress["error"] = str(e)
finally:
with _lock:
_progress["running"] = False
_progress["finished"] = True
_progress["done"] = _progress["total"]
threading.Thread(target=_run, daemon=True).start()
return True
def sync_contract_bars_bg(contract_code: str) -> bool:
"""Sync bars for one contract in background. Returns True if started."""
code = contract_code.upper()
def _run():
db = SessionLocal()
try:
latest = (
db.query(DailyBar.date)
.filter(DailyBar.contract == code)
.order_by(DailyBar.date.desc())
.first()
)
start_date = latest[0].isoformat() if latest else None
bars = fetch_contract_bars(code, start_date)
with _lock:
_progress["total"] = len(bars)
total_bars = len(bars)
inserted = 0
min_date = None
for i, bar in enumerate(bars):
existing = (
db.query(DailyBar)
.filter(DailyBar.contract == code, DailyBar.date == bar["date"])
.first()
)
if not existing:
db.add(DailyBar(
contract=code, date=bar["date"],
open=bar["open"], close=bar["close"],
high=bar["high"], low=bar["low"],
))
inserted += 1
if min_date is None or bar["date"] < min_date:
min_date = bar["date"]
with _lock:
_progress["done"] = i + 1
_progress["label"] = f"同步行情 {code} · {i + 1}/{total_bars}"
if inserted > 0:
db.flush()
_recompute_amp(db, code, from_date=min_date)
db.commit()
with _lock:
_progress["result"] = inserted
finally:
db.close()
return _start_bg(_run, f"同步行情 {code}")
def sync_positions_bg(contract_code: str) -> bool:
"""Sync position rankings for one contract in background. Returns True if started."""
from app.models import PositionRanking
code = contract_code.upper()
def _run():
db = SessionLocal()
try:
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.desc())
.limit(20)
.all()
]
missing = [d for d in bar_dates if d not in existing_dates]
total_missing = len(missing)
with _lock:
_progress["total"] = total_missing
if total_missing == 0:
_progress["label"] = f"同步持仓 {code} · 已是最新"
inserted = 0
for i, d in enumerate(missing):
rankings = fetch_position_rankings(code, d.strftime("%Y%m%d"))
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()
with _lock:
_progress["done"] = i + 1
_progress["label"] = f"同步持仓 {code} · {i + 1}/{total_missing}"
db.commit()
with _lock:
_progress["result"] = inserted
finally:
db.close()
return _start_bg(_run, f"同步持仓 {code}")
def fetch_contract_bars(contract_code: str, start_date: str | None = None) -> list[dict]:
"""Fetch daily OHLCV for a single contract from akshare.
Returns list of {date, open, close, high, low} dicts.
Only returns bars on or after start_date when provided.
"""
import akshare as ak
@@ -22,6 +167,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 +178,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 +242,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 +262,134 @@ 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:
inst = str(row["会员简称"]).strip()
rank = int(row["名次"])
if inst in ("-", ""):
inst = f"-{rank}"
results.append({
"data_type": dtype,
"rank": rank,
"institution": inst,
"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.desc())
.limit(20)
.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}"
+3 -3
View File
@@ -28,6 +28,6 @@ def check_lock(
return (current_price - open_price) >= lock_threshold
def should_meltdown(lock_count: int) -> bool:
"""3锁熔断"""
return lock_count >= 3
def should_meltdown(lock_count: int, max_locks: int = 3) -> bool:
"""仓次数达到上限时熔断"""
return lock_count >= max_locks
+4 -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, positions, trades, option_trades
TEMPLATES_DIR = Path(__file__).parent / "templates"
@@ -55,8 +55,10 @@ app.add_middleware(AuthMiddleware)
app.include_router(auth.router)
app.include_router(contracts.router)
app.include_router(analysis.router)
app.include_router(admin.router)
app.include_router(positions.router)
app.include_router(trades.router)
app.include_router(option_trades.router)
@app.get("/")
+99 -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):
@@ -87,3 +87,93 @@ 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)
max_locks: Mapped[int] = mapped_column(Integer, default=3)
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")
class Trade(Base):
__tablename__ = "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)
direction: Mapped[str] = mapped_column(String(4))
open_date: Mapped[date] = mapped_column(Date)
open_price: Mapped[float] = mapped_column(Float)
open_fee: Mapped[float | None] = mapped_column(Float, nullable=True, default=0)
close_date: Mapped[date | None] = mapped_column(Date, nullable=True)
close_price: Mapped[float | None] = mapped_column(Float, nullable=True)
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.close_price is None:
return None
mul = 20 # glass futures point value
if self.direction == "long":
result = (self.close_price - self.open_price) * mul - (self.open_fee or 0) - (self.close_fee or 0)
else:
result = (self.open_price - self.close_price) * mul - (self.open_fee or 0) - (self.close_fee or 0)
return round(result, 2)
class OptionTrade(Base):
__tablename__ = "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)
option_type: Mapped[str] = mapped_column(String(4))
direction: Mapped[str] = mapped_column(String(4))
strike_price: Mapped[float] = mapped_column(Float)
open_date: Mapped[date] = mapped_column(Date)
open_price: Mapped[float] = mapped_column(Float)
open_fee: Mapped[float | None] = mapped_column(Float, nullable=True, default=0)
close_date: Mapped[date | None] = mapped_column(Date, nullable=True)
close_price: Mapped[float | None] = mapped_column(Float, nullable=True)
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.close_price is None:
return None
mul = 20 # glass futures point value
if self.direction == "sell":
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)
+229
View File
@@ -0,0 +1,229 @@
"""持仓分析 prompt 生成器。
根据最近 5 个交易日的主力持仓排名生成结构化分析 prompt,
按席位属性(家人/大户/其他/噪音)分类汇总,聚焦资金风向变化。
"""
from collections import defaultdict
from datetime import date, timedelta
from app.database import SessionLocal
from app.models import PositionRanking, Contract
# ── 席位分类 ──
FAMILY_NAMES = [
"东方财富", "徽商期货", "方正中期", "中信建投", "平安期货",
]
WHALE_NAMES = [
"永安期货", "中信期货", "国泰君安", "海通期货", "华泰期货",
"高盛期货", "乾坤期货", "摩根大通", "中粮期货",
]
NOISE_NAMES = ["东证期货"]
TYPE_LABEL = {"volume": "成交量", "long": "多单持仓", "short": "空单持仓"}
def _classify(inst: str) -> str:
"""匹配席位分类,返回 家人/大户/噪音/其他。"""
for kw in FAMILY_NAMES:
if kw in inst:
return "家人"
for kw in WHALE_NAMES:
if kw in inst:
return "大户"
for kw in NOISE_NAMES:
if kw in inst:
return "噪音"
return "其他"
def _build_legend() -> str:
"""生成席位分类图例。"""
lines = ["## 席位分类", ""]
lines.append(f"- **家人席位(散户)**: {''.join(FAMILY_NAMES)}")
lines.append(f"- **大户席位(产业/套保)**: {''.join(WHALE_NAMES)}")
lines.append(f"- **噪音席位(忽略)**: {''.join(NOISE_NAMES)}")
lines.append("- **其他席位**: 未归入以上三类的席位")
lines.append("")
return "\n".join(lines)
def _format_number(n: int) -> str:
"""数字格式化为万手。"""
v = n / 10000
sign = "+" if n > 0 else ""
return f"{sign}{v:.2f}"
def build_prompt(contract_code: str | None = None) -> str:
"""生成单个或全部活跃合约的持仓分析 prompt。
Args:
contract_code: 合约代码(如 FG2609),为 None 时分析所有活跃合约。
Returns:
可直接发送给 AI 进行分析的 prompt 文本。
"""
db = SessionLocal()
try:
if contract_code:
codes = [contract_code.upper()]
else:
codes = [
r[0] for r in
db.query(Contract.code)
.filter(Contract.is_active == True)
.order_by(Contract.code)
.all()
]
# 取最近 5 个有持仓数据的交易日
all_dates = sorted({
r[0] for r in
db.query(PositionRanking.date)
.filter(PositionRanking.contract_code.in_(codes))
.distinct()
.all()
}, reverse=True)[:5]
all_dates.reverse() # 从旧到新
if not all_dates:
return "无持仓数据"
# 拉取全部数据
rankings = (
db.query(PositionRanking)
.filter(
PositionRanking.contract_code.in_(codes),
PositionRanking.date.in_(all_dates),
)
.order_by(PositionRanking.contract_code, PositionRanking.date, PositionRanking.data_type, PositionRanking.rank)
.all()
)
# 按 contract → date → data_type 组织
data: dict[str, dict] = defaultdict(lambda: defaultdict(lambda: defaultdict(list)))
for r in rankings:
data[r.contract_code][r.date][r.data_type].append(r)
parts = [_build_legend()]
for code in codes:
if code not in data:
continue
cd = data[code]
parts.append(f"## {code}")
parts.append("")
parts.append(f"分析区间: {min(all_dates)}{max(all_dates)}(共 {len(all_dates)} 个交易日)")
parts.append("")
# ── 每日明细 ──
for d in all_dates:
if d not in cd:
continue
parts.append(f"### {d} {_weekday_zh(d)}")
parts.append("")
for dtype in ["volume", "long", "short"]:
rows = cd[d].get(dtype, [])
if not rows:
continue
parts.append(f"**{TYPE_LABEL[dtype]} Top 20**")
parts.append("")
parts.append("| 排名 | 席位 | 分类 | 持仓量 | 增减 |")
parts.append("|------|------|------|--------|------|")
for r in rows[:20]:
tag = _classify(r.institution)
if tag == "噪音":
continue # 跳过东证
parts.append(
f"| {r.rank} | {r.institution} | {tag} | "
f"{_format_number(r.value)} | {_format_number(r.change)} |"
)
parts.append("")
# ── 每日家人 vs 大户汇总 ──
parts.append(f"### {code} 分类资金变化汇总")
parts.append("")
parts.append("| 日期 | 家人多单增减 | 家人空单增减 | 大户多单增减 | 大户空单增减 | 其他多单增减 | 其他空单增减 |")
parts.append("|------|-------------|-------------|-------------|-------------|-------------|-------------|")
for d in all_dates:
if d not in cd:
continue
cat_changes = defaultdict(lambda: defaultdict(int))
for dtype in ["long", "short"]:
for r in cd[d].get(dtype, []):
tag = _classify(r.institution)
if tag == "噪音":
continue
cat_changes[tag][dtype] += r.change
parts.append(
f"| {d} "
f"| {_format_number(cat_changes['家人']['long'])} "
f"| {_format_number(cat_changes['家人']['short'])} "
f"| {_format_number(cat_changes['大户']['long'])} "
f"| {_format_number(cat_changes['大户']['short'])} "
f"| {_format_number(cat_changes['其他']['long'])} "
f"| {_format_number(cat_changes['其他']['short'])} |"
)
parts.append("")
# ── 5日累计 ──
parts.append(f"### {code} 5日累计资金变化")
parts.append("")
total_cat = defaultdict(lambda: defaultdict(int))
for d in all_dates:
if d not in cd:
continue
for dtype in ["long", "short"]:
for r in cd[d].get(dtype, []):
tag = _classify(r.institution)
if tag == "噪音":
continue
total_cat[tag][dtype] += r.change
parts.append("| 分类 | 5日多单累计 | 5日空单累计 | 净多(多-空) |")
parts.append("|------|------------|------------|--------------|")
for tag in ["家人", "大户", "其他"]:
net = total_cat[tag]["long"] - total_cat[tag]["short"]
net_str = _format_number(net)
parts.append(
f"| {tag} | {_format_number(total_cat[tag]['long'])} "
f"| {_format_number(total_cat[tag]['short'])} | {net_str} |"
)
parts.append("")
# ── 分析引导 ──
parts.append("---")
parts.append("")
parts.append("请基于以上数据进行分析,重点关注:")
parts.append("")
parts.append("1. **家人席位动向**: 5日内多空增减趋势,散户情绪偏多还是偏空,"
"是否有明显的追涨杀跌行为。")
parts.append("2. **大户席位动向**: 产业资金和套保资金的布局方向,"
"多空增减是否与家人席位形成对手盘。")
parts.append("3. **多空力量对比**: 各分类的净多/净空方向,"
"整体市场情绪偏向。")
parts.append("4. **异常信号**: 单日大幅增减的席位,"
"分类资金出现明显分歧的交易日。")
parts.append("5. **行情预判**: 结合席位资金流向,"
"对下一交易日走势给出偏多/偏空/震荡的判断及理由。")
return "\n".join(parts)
finally:
db.close()
def _weekday_zh(d: date) -> str:
return ["周一", "周二", "周三", "周四", "周五", "周六", "周日"][d.weekday()]
if __name__ == "__main__":
import sys
sys.path.insert(0, "/app")
from app.prompt_builder import build_prompt
prompt = build_prompt()
print(prompt)
+31 -12
View File
@@ -1,9 +1,11 @@
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
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_one_contract, sync_contract_bars_bg, sync_positions_bg, get_progress,
)
router = APIRouter(prefix="/admin", tags=["admin"])
@@ -29,9 +31,16 @@ def admin_page(request: Request, db: Session = Depends(get_db)):
.filter(DailyBar.contract == c.code)
.count()
)
pos_days = len({
r[0] for r in
db.query(PositionRanking.date)
.filter(PositionRanking.contract_code == c.code)
.distinct().all()
})
contract_list.append({
"id": c.id, "code": c.code, "name": c.name,
"is_active": c.is_active, "bar_count": bar_count,
"pos_days": pos_days,
})
product_data.append({
@@ -113,7 +122,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,25 +134,35 @@ 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)
@router.post("/sync")
def sync_all(request: Request):
results = sync_active_contracts()
total = sum(results.values())
print(f"[sync] Synced {total} bars across {len(results)} contracts: {results}")
return RedirectResponse(f"/admin/?tab=sync&synced={total}", status_code=303)
@router.post("/sync/{contract_code}")
def sync_single(contract_code: str):
count = sync_one_contract(contract_code.upper())
return RedirectResponse(f"/admin/?tab=sync&synced={count}", status_code=303)
@router.post("/sync/{contract_code}/bg")
def sync_single_bg(contract_code: str):
ok = sync_contract_bars_bg(contract_code.upper())
return JSONResponse({"ok": ok})
@router.post("/sync-positions/{contract_code}/bg")
def sync_positions_bg_ep(contract_code: str):
ok = sync_positions_bg(contract_code.upper())
return JSONResponse({"ok": ok})
@router.get("/sync-status")
def sync_status():
return JSONResponse(get_progress())
@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")
+122 -14
View File
@@ -1,13 +1,16 @@
import math
from datetime import date, timedelta
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from fastapi import APIRouter, Depends, Request, Query
from fastapi.responses import HTMLResponse, PlainTextResponse
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import DailyBar, Contract
from app.models import DailyBar, Contract, PositionRanking
from app.prompt_builder import build_prompt
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]:
@@ -22,11 +25,33 @@ def get_active_contracts(db: Session) -> list[str]:
@router.get("/", response_class=HTMLResponse)
def contract_index(request: Request, db: Session = Depends(get_db)):
active_contracts = get_active_contracts(db)
from app.models import Product
# Build product → contracts mapping, ordered by product code
products = (
db.query(Product)
.order_by(Product.code)
.all()
)
product_groups = []
all_codes = []
for prod in products:
codes = [
c.code for c in prod.contracts if c.is_active
]
if codes:
product_groups.append({
"code": prod.code,
"name": prod.name,
"exchange": prod.exchange,
"contracts": codes,
})
all_codes.extend(codes)
latest = (
db.query(DailyBar)
.filter(DailyBar.contract.in_(active_contracts))
.filter(DailyBar.contract.in_(all_codes))
.order_by(DailyBar.date.desc())
.all()
)
@@ -35,26 +60,40 @@ 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()
)
# Next-day amplitude: mean of latest 5 diffs per contract
contract_next_amp = {}
for code in all_codes:
diffs = [
r[0] for r in
db.query(DailyBar.high - DailyBar.low)
.filter(DailyBar.contract == code)
.order_by(DailyBar.date.desc())
.limit(5)
.all()
]
if len(diffs) >= 5:
contract_next_amp[code] = round(sum(diffs) / 5)
template = request.app.state.templates.get_template("index.html")
return HTMLResponse(
template.render(
request=request,
active_nav="contracts",
contracts=active_contracts,
product_groups=product_groups,
contract_bars=contract_bars,
total_bars=total_bars,
latest_date=latest_bar.date.strftime("%Y-%m-%d") if latest_bar else "",
contract_next_amp=contract_next_amp,
)
)
@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 +102,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 +126,52 @@ 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,
})
pos_totals = {
"long": sum(r.change for r in rankings if r.data_type == "long"),
"short": sum(r.change for r in rankings if r.data_type == "short"),
}
else:
pos_totals = {"long": 0, "short": 0}
# 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 +191,24 @@ 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,
pos_totals=pos_totals,
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,
)
)
@router.get("/{contract}/analysis-prompt", response_class=PlainTextResponse)
def contract_analysis_prompt(contract: str):
"""返回合约的持仓分析 prompt 文本。"""
return build_prompt(contract.upper())
+149
View File
@@ -0,0 +1,149 @@
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 OptionTrade, Contract, Product
router = APIRouter(prefix="/options", tags=["options"])
OPTION_TYPES = [("C", "C 看涨"), ("P", "P 看跌")]
OPTION_DIRECTIONS = [("buy", ""), ("sell", "")]
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 option_page(request: Request, db: Session = Depends(get_db)):
view = request.query_params.get("view", "")
open_trades = (
db.query(OptionTrade).filter(OptionTrade.status == "open")
.order_by(OptionTrade.open_date.desc()).all()
)
closed_trades = (
db.query(OptionTrade).filter(OptionTrade.status == "closed")
.order_by(OptionTrade.close_date.desc()).limit(50).all()
)
template = request.app.state.templates.get_template("options.html")
return HTMLResponse(
template.render(
request=request,
active_nav="options",
product_contracts=get_product_contracts(db),
option_types=OPTION_TYPES,
option_directions=OPTION_DIRECTIONS,
open_trades=open_trades,
closed_trades=closed_trades,
today=today_str(),
weekdays=["周一","周二","周三","周四","周五","周六","周日"],
)
)
@router.post("/open")
def open_trade(
request: Request,
contract_code: str = Form(...),
option_type: str = Form(...),
direction: str = Form(...),
strike_price: float = Form(...),
open_date: str = Form(...),
open_price: float = Form(...),
open_fee: float = Form(0.0),
db: Session = Depends(get_db),
):
code = contract_code.upper()
contract = db.query(Contract).filter(Contract.code == code).first()
product_code = contract.product.code if contract else code[:2]
t = OptionTrade(
product_code=product_code,
contract_code=code,
option_type=option_type,
direction=direction,
strike_price=strike_price,
open_date=date.fromisoformat(open_date),
open_price=open_price,
open_fee=open_fee,
status="open",
)
db.add(t)
db.commit()
return RedirectResponse("/options/", status_code=303)
@router.post("/{trade_id}/close")
def close_trade(
request: Request,
trade_id: int,
close_date: str = Form(...),
close_price: float = Form(...),
close_fee: float = Form(0.0),
db: Session = Depends(get_db),
):
t = db.query(OptionTrade).filter(OptionTrade.id == trade_id).first()
if t and t.status == "open":
t.close_date = date.fromisoformat(close_date)
t.close_price = close_price
t.close_fee = close_fee
t.status = "closed"
db.commit()
return RedirectResponse("/options/", status_code=303)
@router.post("/{trade_id}/edit")
def edit_trade(
request: Request,
trade_id: int,
contract_code: str = Form(...),
option_type: str = Form(...),
direction: str = Form(...),
strike_price: float = Form(...),
open_date: str = Form(...),
open_price: float = Form(...),
open_fee: float = Form(0.0),
close_date: str = Form(""),
close_price: str = Form(""),
close_fee: str = Form(""),
db: Session = Depends(get_db),
):
t = db.query(OptionTrade).filter(OptionTrade.id == trade_id).first()
if t:
code = contract_code.upper()
contract = db.query(Contract).filter(Contract.code == code).first()
t.product_code = contract.product.code if contract else code[:2]
t.contract_code = code
t.option_type = option_type
t.direction = direction
t.strike_price = strike_price
t.open_date = date.fromisoformat(open_date)
t.open_price = open_price
t.open_fee = open_fee
if close_date and close_price:
t.close_date = date.fromisoformat(close_date)
t.close_price = float(close_price)
t.close_fee = float(close_fee) if close_fee else 0.0
db.commit()
return RedirectResponse("/options/", status_code=303)
@router.post("/{trade_id}/delete")
def delete_trade(trade_id: int, db: Session = Depends(get_db)):
t = db.query(OptionTrade).filter(OptionTrade.id == trade_id).first()
if t:
db.delete(t)
db.commit()
return RedirectResponse("/options/", status_code=303)
+175
View File
@@ -0,0 +1,175 @@
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, Product
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]
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
@router.get("/", response_class=HTMLResponse)
def positions_page(request: Request, db: Session = Depends(get_db)):
active_rounds = (
db.query(Round)
.filter(Round.status == "active")
.order_by(Round.started_at.desc())
.all()
)
template = request.app.state.templates.get_template("positions.html")
return HTMLResponse(
template.render(
request=request,
active_nav="positions",
contracts=get_active_contracts(db),
product_contracts=get_product_contracts(db),
active_rounds=active_rounds,
)
)
@router.post("/round")
def create_round(
request: Request,
contract_code: str = Form(...),
daily_hands: int = Form(1),
db: Session = Depends(get_db),
):
code = contract_code.upper()
existing = db.query(Round).filter(
Round.status == "active", Round.contract_code == code
).first()
if existing:
return RedirectResponse(f"/positions/?error={code} 已有活跃轮次", status_code=303)
r = Round(
contract_code=code,
daily_hands=daily_hands,
status="active",
started_at=date.today(),
)
db.add(r)
db.commit()
return RedirectResponse("/positions/", status_code=303)
@router.post("/{round_id}/add")
def add_position(
request: Request,
round_id: int,
open_price: int = Form(...),
amp_threshold: int = Form(...),
lock_price: str = Form(""),
hands: int = Form(1),
db: Session = Depends(get_db),
):
r = db.query(Round).filter(Round.id == round_id).first()
if not r or r.status != "active":
return RedirectResponse("/positions/?error=轮次不存在或已结束", status_code=303)
final_lock = int(lock_price) if lock_price else open_price + amp_threshold
p = Position(
round_id=r.id,
open_price=open_price,
amp_threshold=amp_threshold,
lock_price=final_lock,
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,
lock_price: str = Form(""),
db: Session = Depends(get_db),
):
p = db.query(Position).filter(Position.id == position_id).first()
if p and p.locked_count < p.hands:
if lock_price:
p.lock_price = int(lock_price)
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("/{position_id}/update-lock-price")
def update_lock_price(
position_id: int,
lock_price: int = Form(...),
db: Session = Depends(get_db),
):
p = db.query(Position).filter(Position.id == position_id).first()
if p and p.locked_count == 0:
p.lock_price = lock_price
db.commit()
return RedirectResponse("/positions/", status_code=303)
@router.post("/{round_id}/max-locks")
def update_max_locks(
round_id: int,
max_locks: int = Form(...),
db: Session = Depends(get_db),
):
r = db.query(Round).filter(Round.id == round_id).first()
if r and r.status == "active" and 1 <= max_locks <= 10:
r.max_locks = max_locks
db.commit()
return RedirectResponse("/positions/", status_code=303)
@router.post("/{round_id}/close")
def close_round(
request: Request,
round_id: int,
result: str = Form(...),
db: Session = Depends(get_db),
):
r = db.query(Round).filter(Round.id == round_id).first()
if r and r.status == "active":
r.status = result
db.commit()
return RedirectResponse("/positions/", status_code=303)
@router.post("/{round_id}/delete")
def delete_round(round_id: int, db: Session = Depends(get_db)):
r = db.query(Round).filter(Round.id == round_id).first()
if r:
db.delete(r)
db.commit()
return RedirectResponse("/positions/", status_code=303)
+150
View File
@@ -0,0 +1,150 @@
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 Trade, Contract, Product
router = APIRouter(prefix="/trades", tags=["trades"])
DIRECTIONS = [("short", ""), ("long", "")]
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]
def get_product_contracts(db: Session) -> dict:
"""Return {product_code: [contract_codes]} for active contracts."""
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 trades_page(request: Request, db: Session = Depends(get_db)):
open_trades = (
db.query(Trade).filter(Trade.status == "open")
.order_by(Trade.open_date.desc()).all()
)
closed_trades = (
db.query(Trade).filter(Trade.status == "closed")
.order_by(Trade.close_date.desc()).limit(50).all()
)
template = request.app.state.templates.get_template("trades.html")
return HTMLResponse(
template.render(
request=request,
active_nav="trades",
contracts=get_active_contracts(db),
product_contracts=get_product_contracts(db),
directions=DIRECTIONS,
open_trades=open_trades,
closed_trades=closed_trades,
today=today_str(),
weekdays=["周一","周二","周三","周四","周五","周六","周日"],
)
)
@router.post("/open")
def open_trade(
request: Request,
contract_code: str = Form(...),
direction: str = Form(...),
open_date: str = Form(...),
open_price: float = Form(...),
open_fee: float = Form(0.0),
db: Session = Depends(get_db),
):
code = contract_code.upper()
contract = db.query(Contract).filter(Contract.code == code).first()
product_code = contract.product.code if contract else code[:2]
t = Trade(
product_code=product_code,
contract_code=code,
direction=direction,
open_date=date.fromisoformat(open_date),
open_price=open_price,
open_fee=open_fee,
status="open",
)
db.add(t)
db.commit()
return RedirectResponse("/trades/", status_code=303)
@router.post("/{trade_id}/close")
def close_trade(
request: Request,
trade_id: int,
close_date: str = Form(...),
close_price: float = Form(...),
close_fee: float = Form(0.0),
db: Session = Depends(get_db),
):
t = db.query(Trade).filter(Trade.id == trade_id).first()
if t and t.status == "open":
t.close_date = date.fromisoformat(close_date)
t.close_price = close_price
t.close_fee = close_fee
t.status = "closed"
db.commit()
return RedirectResponse("/trades/", status_code=303)
@router.post("/{trade_id}/edit")
def edit_trade(
request: Request,
trade_id: int,
contract_code: str = Form(...),
direction: str = Form(...),
open_date: str = Form(...),
open_price: float = Form(...),
open_fee: float = Form(0.0),
close_date: str = Form(""),
close_price: str = Form(""),
close_fee: str = Form(""),
db: Session = Depends(get_db),
):
t = db.query(Trade).filter(Trade.id == trade_id).first()
if t:
code = contract_code.upper()
contract = db.query(Contract).filter(Contract.code == code).first()
t.product_code = contract.product.code if contract else code[:2]
t.contract_code = code
t.direction = direction
t.open_date = date.fromisoformat(open_date)
t.open_price = open_price
t.open_fee = open_fee
if close_date and close_price:
t.close_date = date.fromisoformat(close_date)
t.close_price = float(close_price)
t.close_fee = float(close_fee) if close_fee else 0.0
db.commit()
return RedirectResponse("/trades/", status_code=303)
@router.post("/{trade_id}/delete")
def delete_trade(trade_id: int, db: Session = Depends(get_db)):
t = db.query(Trade).filter(Trade.id == trade_id).first()
if t:
db.delete(t)
db.commit()
return RedirectResponse("/trades/", status_code=303)
+16 -200
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,178 +27,24 @@ 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 _migrate(engine):
"""Add missing columns to existing tables (SQLite doesn't auto-migrate)."""
import sqlite3
conn = engine.raw_connection()
try:
cur = conn.cursor()
cur.execute("PRAGMA table_info(rounds)")
cols = {row[1] for row in cur.fetchall()}
if "max_locks" not in cols:
cur.execute("ALTER TABLE rounds ADD COLUMN max_locks INTEGER DEFAULT 3")
conn.commit()
finally:
conn.close()
def seed():
Base.metadata.create_all(bind=engine)
_migrate(engine)
db = SessionLocal()
try:
@@ -260,38 +106,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()
+150 -18
View File
@@ -10,7 +10,7 @@
.tab-btn {
padding: 10px 20px; border: none; background: none;
font-size: 0.88rem; font-weight: 500; color: var(--sub);
cursor: pointer; border-bottom: 2px solid transparent;
cursor: pointer; text-decoration: none; border-bottom: 2px solid transparent;
margin-bottom: -2px; transition: color .12s, border-color .12s;
}
.tab-btn:hover { color: var(--fg); }
@@ -29,15 +29,18 @@
{# ═══════════════════ Tab: 数据同步 ═══════════════════ #}
<div class="tab-panel{% if tab == 'sync' %} active{% endif %}" id="tab-sync">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:20px;padding:14px 18px;background:var(--surface);border:1px solid var(--border);border-radius:10px;">
<span style="font-size:0.85rem;color:var(--sub);">全部合约</span>
<form method="post" action="/admin/sync" style="display:inline;">
<button type="submit" class="btn btn-primary" style="font-size:0.82rem;padding:6px 16px;">同步全部</button>
</form>
{% if request.query_params.get('synced') %}
<span style="font-size:0.82rem;color:var(--success-fg);">✓ 已同步 {{ request.query_params.synced }} 条</span>
{% endif %}
<span style="font-size:0.78rem;color:var(--sub);margin-left:auto;">{{ total_contracts }} 个合约</span>
<div style="display:flex;align-items:center;gap:12px;margin-bottom:14px;">
<span style="font-size:0.78rem;color:var(--sub);">共 {{ total_contracts }} 个合约</span>
</div>
<div id="sync-progress" style="display:none;margin-bottom:18px;padding:12px 16px;background:var(--surface);border:1px solid var(--border);border-radius:8px;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px;">
<span id="sync-progress-label" style="font-size:0.82rem;color:var(--fg);">正在同步...</span>
<span id="sync-progress-pct" style="font-size:0.8rem;color:var(--sub);margin-left:auto;"></span>
</div>
<div style="width:100%;height:5px;background:var(--border);border-radius:3px;overflow:hidden;">
<div id="sync-progress-bar" style="width:0%;height:100%;background:var(--accent);border-radius:3px;transition:width .3s;"></div>
</div>
</div>
{% if products %}
@@ -51,24 +54,24 @@
<span style="font-size:0.78rem;color:var(--sub);">{{ p.exchange }}</span>
<span style="font-size:0.78rem;color:var(--sub);margin-left:8px;">{{ p.contracts|length }} 合约</span>
<form method="post" action="/admin/sync/product/{{ p.id }}" style="display:inline;margin-left:auto;" onclick="event.stopPropagation()">
<button type="submit" class="btn" style="font-size:0.78rem;padding:4px 14px;background:var(--accent);color:#fff;border-radius:5px;">同步该品种</button>
<button type="submit" class="btn" style="font-size:0.78rem;padding:4px 14px;background:var(--accent);color:#fff;border-radius:5px;">同步行情</button>
</form>
</div>
<div class="product-body" style="display:none;">
<table style="font-size:0.84rem;">
<tr><th style="text-align:left;padding:8px 14px;">合约</th><th style="text-align:center;padding:8px 14px;">数据条数</th><th style="text-align:center;padding:8px 14px;">状态</th><th style="text-align:center;padding:8px 14px;">操作</th></tr>
<tr><th style="text-align:left;padding:8px 14px;">合约</th><th style="text-align:center;padding:8px 14px;">行情</th><th style="text-align:center;padding:8px 14px;">持仓</th><th style="text-align:center;padding:8px 14px;">状态</th><th style="text-align:center;padding:8px 14px;">操作</th></tr>
{% for c in p.contracts %}
<tr>
<td style="text-align:left;padding:6px 14px;"><strong>{{ c.code }}</strong></td>
<td style="text-align:center;padding:6px 14px;">{{ c.bar_count }}</td>
<td style="text-align:center;padding:6px 14px;">{{ c.pos_days }}</td>
<td style="text-align:center;padding:6px 14px;">
{% if c.is_active %}<span class="badge badge-up">启用</span>{% else %}<span class="badge badge-down">停用</span>{% endif %}
</td>
<td style="text-align:center;padding:6px 14px;">
<a href="/contracts/{{ c.code }}" style="color:var(--accent);text-decoration:none;font-size:0.82rem;">查看</a>
<form method="post" action="/admin/sync/{{ c.code }}" style="display:inline;margin-left:8px;">
<button style="background:none;border:none;color:var(--accent);cursor:pointer;font-size:0.82rem;">同步</button>
</form>
<button style="background:none;border:none;color:var(--accent);cursor:pointer;font-size:0.82rem;margin-left:12px;" onclick="syncBars('{{ c.code }}')">↻ 行情</button>
<button style="background:none;border:none;color:var(--success);cursor:pointer;font-size:0.82rem;margin-left:10px;" onclick="syncPositions('{{ c.code }}')">持仓</button>
</td>
</tr>
{% endfor %}
@@ -154,10 +157,16 @@
</form>
</div>
{# ── Contract tables per product ── #}
{# ── Product tabs for contract list ── #}
{% set ctab = request.query_params.get('ctab', products[0].code if products else '') %}
<div class="tabs" style="margin-top:24px;">
{% for p in products %}
<a href="/admin/?tab=contract&ctab={{ p.code }}" class="tab-btn{% if ctab == p.code %} active{% endif %}">{{ p.code }} · {{ p.name }}</a>
{% endfor %}
</div>
{% for p in products %}
{% if p.contracts %}
<div class="section-title" style="margin-top:{% if not loop.first %}24px{% else %}0{% endif %};">{{ p.code }} · {{ p.name }}</div>
{% if p.code == ctab and p.contracts %}
<div class="table-wrap" style="margin-bottom:20px;">
<table>
<tr><th>合约代码</th><th>月份</th><th>状态</th><th>操作</th></tr>
@@ -190,6 +199,100 @@
</div>
<script>
function escapeHtml(s) {
var d = document.createElement('div');
d.appendChild(document.createTextNode(s));
return d.innerHTML;
}
var syncTimer = null;
var progressShownAt = 0;
function showProgress() {
progressShownAt = Date.now();
document.getElementById('sync-progress').style.display = 'block';
document.getElementById('sync-progress-bar').style.width = '0%';
document.getElementById('sync-progress-pct').textContent = '';
document.getElementById('sync-progress-label').textContent = '正在同步...';
}
function hideProgress() {
clearInterval(syncTimer);
syncTimer = null;
document.getElementById('sync-progress').style.display = 'none';
}
function pollProgress() {
fetch('/admin/sync-status')
.then(function(r) { return r.json(); })
.then(function(data) {
var pct = data.total > 0 ? Math.round(data.done / data.total * 100) : 0;
document.getElementById('sync-progress-bar').style.width = pct + '%';
document.getElementById('sync-progress-pct').textContent = pct + '%';
document.getElementById('sync-progress-label').textContent = data.label;
if (data.error) {
clearInterval(syncTimer);
syncTimer = null;
document.getElementById('sync-progress-bar').style.background = 'var(--danger)';
document.getElementById('sync-progress-label').innerHTML =
'<span style="color:var(--danger-fg);">✕ ' + escapeHtml(data.label) + '</span>' +
'<span style="display:block;font-size:0.78rem;color:var(--danger-fg);margin-top:4px;">' + escapeHtml(data.error) + '</span>';
document.getElementById('sync-progress-pct').textContent = '';
document.getElementById('sync-progress').insertAdjacentHTML('beforeend',
'<button onclick="hideProgress()" style="display:block;margin-top:10px;padding:4px 14px;border:1px solid var(--border);border-radius:5px;background:var(--surface);color:var(--fg);cursor:pointer;font-size:0.8rem;">关闭</button>');
return;
}
if (data.finished) {
var elapsed = Date.now() - progressShownAt;
var delay = elapsed < 1000 ? 1000 - elapsed : 0;
setTimeout(function() {
clearInterval(syncTimer);
syncTimer = null;
document.getElementById('sync-progress-bar').style.width = '100%';
if (data.result > 0) {
document.getElementById('sync-progress-label').textContent = data.label + ' ✓ · +' + data.result;
} else if (data.total > 0) {
document.getElementById('sync-progress-label').textContent = data.label + ' ⚠ 无新数据,历史日期无可用持仓';
} else {
document.getElementById('sync-progress-label').textContent = data.label + ' ✓ 已是最新';
}
document.getElementById('sync-progress-pct').textContent = '';
document.getElementById('sync-progress').insertAdjacentHTML('beforeend',
'<button onclick="hideProgress()" style="display:block;margin-top:10px;padding:4px 14px;border:1px solid var(--border);border-radius:5px;background:var(--surface);color:var(--fg);cursor:pointer;font-size:0.8rem;">关闭</button>');
if (data.result > 0) {
var expanded = [];
document.querySelectorAll('.product-body').forEach(function(b, i) {
if (b.style.display === 'block') expanded.push(i);
});
try { sessionStorage.setItem('ft-expanded', JSON.stringify(expanded)); } catch(e) {}
setTimeout(function() { location.reload(); }, 1200);
}
}, delay);
}
});
}
function startSync(url) {
if (syncTimer) { clearInterval(syncTimer); syncTimer = null; }
showProgress();
fetch(url, { method: 'POST' })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.ok) {
// Another sync is already running — just show its progress
syncTimer = setInterval(pollProgress, 500);
pollProgress();
return;
}
syncTimer = setInterval(pollProgress, 500);
});
}
function syncBars(code) { startSync('/admin/sync/' + code + '/bg'); }
function syncPositions(code) { startSync('/admin/sync-positions/' + code + '/bg'); }
function switchTab(name) {
document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); });
document.querySelectorAll('.tab-panel').forEach(function(p) { p.classList.remove('active'); });
@@ -212,5 +315,34 @@ function toggleProduct(header) {
}
}
// Restore expanded accordion state after reload
(function() {
try {
var expanded = JSON.parse(sessionStorage.getItem('ft-expanded') || '[]');
var bodies = document.querySelectorAll('.product-body');
expanded.forEach(function(i) {
if (bodies[i]) {
bodies[i].style.display = 'block';
var arrow = bodies[i].previousElementSibling.querySelector('.collapse-arrow');
if (arrow) arrow.style.transform = 'rotate(0deg)';
}
});
sessionStorage.removeItem('ft-expanded');
} catch(e) {}
})();
// Auto-show progress bar if a sync is already running
(function() {
fetch('/admin/sync-status')
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.running) {
showProgress();
syncTimer = setInterval(pollProgress, 500);
pollProgress();
}
});
})();
</script>
{% endblock %}
-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 %}
+32 -12
View File
@@ -59,6 +59,11 @@
border-left-color: var(--accent); font-weight: 600;
}
.sidebar-nav .icon { font-size: 1.1rem; width: 22px; text-align: center; }
.nav-group .nav-arrow { font-size: 0.65rem; margin-left: auto; transition: transform .2s; color: var(--sub); }
.nav-group.open .nav-arrow { transform: rotate(-180deg); }
.nav-group .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);
}
@@ -128,8 +133,8 @@
/* ── 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-up { background: var(--danger-bg); color: var(--danger-fg); }
.badge-down { background: var(--success-bg); color: var(--success-fg); }
.badge-warn { background: var(--warn-bg); color: var(--warn-fg); }
/* ── Forms ── */
@@ -177,15 +182,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,8 +203,14 @@
<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 href="/positions/" class="{% if active_nav == 'positions' %}active{% endif %}">
<span class="icon">📐</span> 对冲管理
</a>
<a href="/trades/" class="{% if active_nav == 'trades' %}active{% endif %}">
<span class="icon">📝</span> 交易记录
</a>
<a href="/options/" class="{% if active_nav == 'options' %}active{% endif %}">
<span class="icon">📊</span> 期权交易
</a>
<a href="/admin/" class="{% if active_nav == 'admin' %}active{% endif %}">
<span class="icon">⚙️</span> 系统管理
@@ -236,9 +250,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) {}
@@ -246,6 +262,10 @@ function setTheme(dark) {
function toggleTheme() {
setTheme(document.documentElement.getAttribute('data-theme') !== 'dark');
}
function toggleNavGroup(e, id) {
e.preventDefault();
document.getElementById(id).classList.toggle('open');
}
(function() {
try {
var s = localStorage.getItem('ft-theme');
+174 -43
View File
@@ -10,28 +10,23 @@
<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>
{% if page == 1 and next_date %}
<div style="background:var(--accent-light);border:1px solid var(--accent);border-radius:8px;padding:12px 18px;margin-bottom:14px;display:flex;align-items:center;gap:16px;">
<span style="font-size:0.78rem;color:var(--sub);">📅 次日预测</span>
<span style="font-weight:600;">{{ next_date }} {{ next_weekday }}</span>
<span style="color:var(--sub);">预计振幅</span>
<span class="amp-cell" style="color:var(--accent);font-weight:700;font-size:1.1rem;cursor:pointer;" data-next="1">{{ next_amp }}</span>
</div>
{% endif %}
<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 %}
<tr style="background:var(--accent-light);font-weight:500;">
<td>{{ next_date }}</td>
<td>{{ next_weekday }}</td>
<td class="na"></td>
<td class="na"></td>
<td class="na"></td>
<td class="na"></td>
<td class="na"></td>
<td><span class="amp-cell" style="color:var(--accent);font-weight:700;" data-next="1">{{ next_amp }}</span></td>
</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,58 +46,194 @@
</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;">
<button onclick="openAnalysisDrawer()" style="margin-left:auto;padding:6px 16px;border:1px solid var(--accent);border-radius:5px;background:var(--accent-light);color:var(--accent);cursor:pointer;font-size:0.82rem;font-weight:600;white-space:nowrap;">AI 分析</button>
</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(--danger-fg){% elif row.change < 0 %}var(--success-fg){% else %}var(--sub){% endif %};">
{% if row.change > 0 %}+{% endif %}{{ row.change }}
</td>
</tr>
{% endfor %}
{% if dtype != 'volume' %}
<tr style="background:var(--th-bg);font-weight:600;">
<td></td><td style="text-align:right;">增减合计</td>
<td></td>
<td style="color:{% if pos_totals[dtype] > 0 %}var(--danger-fg){% elif pos_totals[dtype] < 0 %}var(--success-fg){% else %}var(--sub){% endif %};">
{% if pos_totals[dtype] > 0 %}+{% endif %}{{ pos_totals[dtype] }}
</td>
</tr>
{% endif %}
</table>
</div>
{% endfor %}
</div>
{% endif %}
<div id="analysisOverlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.35);z-index:99;" onclick="closeAnalysisDrawer()"></div>
<div id="analysisDrawer" style="display:none;position:fixed;top:0;right:0;width:700px;max-width:95vw;height:100%;background:var(--surface);border-left:1px solid var(--border);z-index:100;box-shadow:-4px 0 24px rgba(0,0,0,.12);transform:translateX(100%);transition:transform .25s ease;display:flex;flex-direction:column;">
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid var(--border);">
<h3 style="margin:0;font-size:1rem;">持仓分析 {{ contract }}</h3>
<div style="display:flex;gap:8px;">
<button onclick="copyPrompt()" style="padding:4px 12px;border:1px solid var(--border);border-radius:4px;background:var(--surface);color:var(--fg);cursor:pointer;font-size:0.78rem;">复制</button>
<button onclick="closeAnalysisDrawer()" style="background:none;border:none;font-size:1.3rem;cursor:pointer;color:var(--sub);line-height:1;">&times;</button>
</div>
</div>
<pre id="analysisContent" style="flex:1;margin:0;padding:16px 20px;overflow-y:auto;font-size:0.78rem;line-height:1.6;white-space:pre-wrap;word-break:break-all;color:var(--fg);background:var(--bg);">加载中...</pre>
</div>
<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>';
document.getElementById('overlay').classList.add('show');
document.getElementById('drawer').classList.add('show');
var row = cell.parentElement.parentElement;
if (activeRow) activeRow.classList.remove('active');
row.classList.add('active');
activeRow = row;
if (!isNext) {
var row = cell.parentElement.parentElement;
if (activeRow) activeRow.classList.remove('active');
row.classList.add('active');
activeRow = row;
}
}
var promptText = '';
function openAnalysisDrawer() {
var overlay = document.getElementById('analysisOverlay');
var drawer = document.getElementById('analysisDrawer');
drawer.style.display = 'flex';
overlay.style.display = 'block';
requestAnimationFrame(function() {
drawer.style.transform = 'translateX(0)';
});
var content = document.getElementById('analysisContent');
if (promptText) {
content.textContent = promptText;
return;
}
content.textContent = '加载中...';
fetch('/contracts/{{ contract }}/analysis-prompt')
.then(function(r) { return r.text(); })
.then(function(t) {
promptText = t;
content.textContent = t;
})
.catch(function() { content.textContent = '加载失败'; });
}
function closeAnalysisDrawer() {
document.getElementById('analysisOverlay').style.display = 'none';
document.getElementById('analysisDrawer').style.transform = 'translateX(100%)';
}
function copyPrompt() {
navigator.clipboard.writeText(promptText).then(function() {
showToast('已复制到剪贴板');
});
}
function showToast(msg) {
var m = document.getElementById('toastMask');
if (!m) {
m = document.createElement('div');
m.id = 'toastMask';
m.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.3);z-index:999;display:flex;align-items:center;justify-content:center;transition:opacity .25s;opacity:0;';
m.onclick = function() { hideToast(); };
var b = document.createElement('div');
b.id = 'toastBox';
b.style.cssText = 'background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:32px 40px;text-align:center;box-shadow:0 8px 32px rgba(0,0,0,.18);';
m.appendChild(b);
document.body.appendChild(m);
}
var box = document.getElementById('toastBox');
box.innerHTML = '<div style="font-size:2rem;margin-bottom:12px;">&#9989;</div><p style="font-size:1rem;font-weight:600;color:var(--fg);">' + msg + '</p>';
m.style.display = 'flex';
requestAnimationFrame(function() { m.style.opacity = '1'; });
clearTimeout(m._tid);
m._tid = setTimeout(hideToast, 1800);
}
function hideToast() {
var m = document.getElementById('toastMask');
if (!m) return;
m.style.opacity = '0';
setTimeout(function() { m.style.display = 'none'; }, 250);
}
</script>
{% endblock %}
+34 -21
View File
@@ -4,32 +4,35 @@
{% 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>
{% set active_product = request.query_params.get('product', product_groups[0].code if product_groups else '') %}
<style>
.tabs { display: flex; gap: 0; margin-bottom: 24px; border-bottom: 2px solid var(--border); }
.tab-btn {
padding: 10px 20px; border: none; background: none;
font-size: 0.88rem; font-weight: 500; color: var(--sub);
cursor: pointer; text-decoration: none; border-bottom: 2px solid transparent;
margin-bottom: -2px; transition: color .12s, border-color .12s;
}
.tab-btn:hover { color: var(--fg); }
.tab-btn.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
</style>
<div class="tabs">
{% for pg in product_groups %}
<a href="/contracts/?product={{ pg.code }}" class="tab-btn{% if active_product == pg.code %} active{% endif %}">
{{ pg.code }} · {{ pg.name }}
</a>
{% endfor %}
</div>
{% for pg in product_groups %}
{% if pg.code == active_product %}
<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><th>合约</th><th>日期</th><th>开盘</th><th>收盘</th><th>最高</th><th>最低</th><th>振幅</th><th>次日振幅</th><th>操作</th></tr>
{% for c in pg.contracts %}
<tr>
<td><strong>{{ c }}</strong></td>
{% if c in contract_bars %}
@@ -45,12 +48,22 @@
<span class="na"></span>
{% endif %}
</td>
<td>
{% if c in contract_next_amp %}
<span class="badge badge-warn">{{ contract_next_amp[c] }}</span>
{% else %}
<span class="na"></span>
{% endif %}
</td>
{% else %}
<td colspan="6" class="na">暂无数据</td>
<td colspan="7" class="na">暂无数据</td>
{% endif %}
<td><a href="/contracts/{{ c }}" style="color:var(--accent);text-decoration:none;">详情 →</a></td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
{% endfor %}
{% endblock %}
+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) {}
+415
View File
@@ -0,0 +1,415 @@
{% extends "base.html" %}
{% block title %}期权交易{% endblock %}
{% block heading %}期权交易{% endblock %}
{% block breadcrumb %}期权开平仓记录{% endblock %}
{% block content %}
{% set view = request.query_params.get('view', '') %}
<style>
.tabs { display: flex; gap: 0; margin-bottom: 24px; border-bottom: 2px solid var(--border); }
.tab-btn {
padding: 10px 20px; border: none; background: none;
font-size: 0.88rem; font-weight: 500; color: var(--sub);
cursor: pointer; text-decoration: none; border-bottom: 2px solid transparent;
margin-bottom: -2px; transition: color .12s, border-color .12s;
}
.tab-btn:hover { color: var(--fg); }
.tab-btn.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
</style>
<div class="tabs">
<a href="/options/" class="tab-btn{% if view != 'closed' %} active{% endif %}">📋 持仓</a>
<a href="/options/?view=closed" class="tab-btn{% if view == 'closed' %} active{% endif %}">📊 已平仓</a>
</div>
{% if view != 'closed' %}
<div class="tab-panel active">
{# ── 新建开仓 ── #}
<div class="section-title">新建开仓</div>
<div class="form-card" style="margin-bottom:28px;max-width:100%;">
<form method="post" action="/options/open">
<div style="display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap;">
<div class="form-group" style="margin-bottom:0;flex:0 0 auto;min-width:100px;">
<label>品种</label>
<select id="productSelect" required onchange="filterContracts()">
<option value="">--</option>
{% for pcode in product_contracts %}
<option value="{{ pcode }}">{{ pcode }}</option>
{% endfor %}
</select>
</div>
<div class="form-group" style="margin-bottom:0;flex:0 0 auto;min-width:120px;">
<label>合约</label>
<select name="contract_code" id="contractSelect" required disabled>
<option value="">-- 先选品种 --</option>
</select>
</div>
<div class="form-group" style="margin-bottom:0;flex:0 0 auto;min-width:70px;">
<label>类型</label>
<select name="option_type" required>
{% for v, label in option_types %}
<option value="{{ v }}">{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="form-group" style="margin-bottom:0;flex:0 0 auto;min-width:60px;">
<label>买卖</label>
<select name="direction" required>
{% for v, label in option_directions %}
<option value="{{ v }}">{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="form-group" style="margin-bottom:0;flex:0 0 auto;min-width:90px;">
<label>行权价</label>
<input type="number" step="any" name="strike_price" required placeholder="1300" style="font-size:0.9rem;">
</div>
<div class="form-group" style="margin-bottom:0;flex:0 0 auto;min-width:80px;">
<label>权利金</label>
<input type="number" step="any" name="open_price" required placeholder="25" style="font-size:0.9rem;">
</div>
<div class="form-group" style="margin-bottom:0;flex:0 0 auto;min-width:130px;">
<label>开仓日期</label>
<input type="date" name="open_date" value="{{ today }}" required style="font-size:0.85rem;">
</div>
<div class="form-group" style="margin-bottom:0;flex:0 0 auto;min-width:80px;">
<label>手续费</label>
<input type="number" step="any" name="open_fee" value="0" style="font-size:0.9rem;">
</div>
<button type="submit" class="btn btn-primary" style="font-size:0.82rem;padding:8px 18px;">开仓</button>
</div>
</form>
</div>
{# ── 持仓列表 ── #}
<div class="section-title">持仓中 · {{ open_trades|length }} 笔</div>
{% if open_trades %}
<div class="table-wrap">
<table>
<tr><th>品种</th><th>合约</th><th>类型</th><th>买卖</th><th>行权价</th><th>权利金</th><th>开仓日期</th><th>手续费</th><th>操作</th></tr>
{% for t in open_trades %}
<tr>
<td>{{ t.product_code }}</td>
<td><strong>{{ t.contract_code.replace(t.product_code, '', 1) }}</strong></td>
<td>
{% if t.option_type == 'C' %}
<span class="badge badge-up">C</span>
{% else %}
<span class="badge badge-down">P</span>
{% endif %}
</td>
<td>
{% if t.direction == 'sell' %}
<span class="badge badge-down"></span>
{% else %}
<span class="badge badge-up"></span>
{% endif %}
</td>
<td>{{ t.strike_price }}</td>
<td>{{ t.open_price }}</td>
<td>{{ t.open_date }} {{ weekdays[t.open_date.weekday()] }}</td>
<td>{{ t.open_fee or 0 }}</td>
<td>
<button style="background:var(--accent);color:#fff;border:none;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.78rem;"
onclick="showCloseForm({{ t.id }})">平仓</button>
<button style="background:var(--success-bg);color:var(--success-fg);border:1px solid var(--success);padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.78rem;margin-left:8px;"
onclick="showEditForm('option', {{ t.id }}, '{{ t.product_code }}', '{{ t.contract_code }}', '{{ t.option_type }}', '{{ t.direction }}', {{ t.strike_price }}, {{ t.open_price }}, {{ t.open_fee or 0 }}, '{{ t.open_date }}', null, null, null, '{{ t.status }}')">编辑</button>
<form method="post" action="/options/{{ t.id }}/delete" style="display:inline;margin-left:8px;">
<button style="background:transparent;color:#dc2626;border:1px solid #ef4444;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.78rem;" onclick="confirmDelete(event, '确定删除此记录?', this.closest('form').action)">删除</button>
</form>
</td>
</tr>
{% endfor %}
</table>
</div>
{% else %}
<div style="text-align:center;padding:40px;color:var(--sub);">暂无持仓</div>
{% endif %}
{# ── 平仓弹窗 ── #}
<div id="closeFormOverlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.35);z-index:99;" onclick="hideCloseForm()"></div>
<div id="closeFormBox" style="display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:24px;z-index:100;width:360px;max-width:90vw;">
<h3 style="margin-bottom:16px;">平仓</h3>
<form method="post" id="closeForm">
<div class="form-group">
<label>平仓日期</label>
<input type="date" name="close_date" value="{{ today }}" required style="font-size:0.85rem;">
</div>
<div class="form-group">
<label>平仓价格</label>
<input type="number" step="any" name="close_price" required style="font-size:0.9rem;">
</div>
<div class="form-group">
<label>手续费</label>
<input type="number" step="any" name="close_fee" value="0" style="font-size:0.9rem;">
</div>
<div style="display:flex;gap:10px;justify-content:flex-end;">
<button type="button" class="btn" style="background:var(--th-bg);color:var(--fg);" onclick="hideCloseForm()">取消</button>
<button type="submit" class="btn btn-primary">确认平仓</button>
</div>
</form>
</div>
</div>
{% else %}
<div class="tab-panel active">
{# ═══════════════ 已平仓 ═══════════════ #}
{% if closed_trades %}
<div class="section-title">已平仓 · {{ closed_trades|length }} 笔</div>
<div class="table-wrap">
<table>
<tr><th>品种</th><th>合约</th><th>类型</th><th>买卖</th><th>行权价</th><th>开仓</th><th>平仓</th><th>持仓</th><th>权利金</th><th>平仓价</th><th>开仓费</th><th>平仓费</th><th>盈亏</th><th>操作</th></tr>
{% for t in closed_trades %}
<tr>
<td>{{ t.product_code }}</td>
<td><strong>{{ t.contract_code.replace(t.product_code, '', 1) }}</strong></td>
<td>
{% if t.option_type == 'C' %}
<span class="badge badge-up">C</span>
{% else %}
<span class="badge badge-down">P</span>
{% endif %}
</td>
<td>
{% if t.direction == 'sell' %}
<span class="badge badge-down"></span>
{% else %}
<span class="badge badge-up"></span>
{% endif %}
</td>
<td>{{ t.strike_price }}</td>
<td style="font-size:0.8rem;">{{ t.open_date }} {{ weekdays[t.open_date.weekday()] }}</td>
<td style="font-size:0.8rem;">{{ t.close_date }} {{ weekdays[t.close_date.weekday()] }}</td>
<td>{{ (t.close_date - t.open_date).days }}天</td>
<td>{{ t.open_price }}</td>
<td>{{ t.close_price }}</td>
<td>{{ t.open_fee or 0 }}</td>
<td>{{ t.close_fee or 0 }}</td>
<td>
{% set p = t.pnl %}
{% if p is not none %}
<span style="font-weight:700;{% if p > 0 %}color:var(--success-fg);{% elif p < 0 %}color:var(--danger-fg);{% else %}color:var(--sub);{% endif %}">
{% if p > 0 %}+{% endif %}{{ p }}
</span>
<span style="color:var(--sub);cursor:pointer;font-size:0.75rem;margin-left:4px;"
onclick="showPnlDrawer('{{ t.product_code }} {{ t.contract_code.replace(t.product_code, '', 1) }} {{ t.option_type }}', '{{ t.direction }}', {{ t.open_price }}, {{ t.close_price }}, {{ t.open_fee or 0 }}, {{ t.close_fee or 0 }}, {{ p }})" title="计算过程">&#9432;</span>
{% endif %}
</td>
<td>
<button style="background:var(--success-bg);color:var(--success-fg);border:1px solid var(--success);padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.78rem;margin-left:8px;"
onclick="showEditForm('option', {{ t.id }}, '{{ t.product_code }}', '{{ t.contract_code }}', '{{ t.option_type }}', '{{ t.direction }}', {{ t.strike_price }}, {{ t.open_price }}, {{ t.open_fee or 0 }}, '{{ t.open_date }}', {{ t.close_price }}, {{ t.close_fee or 0 }}, '{{ t.close_date }}', '{{ t.status }}')">编辑</button>
<form method="post" action="/options/{{ t.id }}/delete" style="display:inline;margin-left:8px;">
<button style="background:transparent;color:#dc2626;border:1px solid #ef4444;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.78rem;" onclick="confirmDelete(event, '确定删除此记录?', this.closest('form').action)">删除</button>
</form>
</td>
</tr>
{% endfor %}
</table>
</div>
{% 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.open_fee or 0) %}
{% set ns.total_close_fee = ns.total_close_fee + (t.close_fee or 0) %}
{% endfor %}
<div style="display:flex;gap:24px;margin-bottom:24px;padding:12px 18px;background:var(--surface);border:1px solid var(--border);border-radius:8px;font-size:0.88rem;flex-wrap:wrap;">
<span>盈亏合计 <b style="{% if ns.total_pnl > 0 %}color:var(--success-fg);{% elif ns.total_pnl < 0 %}color:var(--danger-fg);{% endif %}">{% if ns.total_pnl > 0 %}+{% endif %}{{ '%.2f'|format(ns.total_pnl) }}</b></span>
<span style="color:var(--sub);">|</span>
<span>开仓手续费 <b>{{ '%.2f'|format(ns.total_open_fee) }}</b></span>
<span style="color:var(--sub);">|</span>
<span>平仓手续费 <b>{{ '%.2f'|format(ns.total_close_fee) }}</b></span>
<span style="color:var(--sub);">|</span>
<span>手续费合计 <b>{{ '%.2f'|format(ns.total_open_fee + ns.total_close_fee) }}</b></span>
</div>
{% else %}
<div style="text-align:center;padding:60px;color:var(--sub);">暂无已平仓记录</div>
{% endif %}
</div>
{% endif %}
{% if view != 'closed' %}
<script>
var productContracts = {{ product_contracts | tojson }};
function filterContracts() {
var prod = document.getElementById('productSelect').value;
var sel = document.getElementById('contractSelect');
sel.innerHTML = '<option value="">-- 选择合约 --</option>';
sel.disabled = !prod;
if (prod && productContracts[prod]) {
productContracts[prod].forEach(function(c) {
var display = c.startsWith(prod) ? c.substring(prod.length) : c;
sel.innerHTML += '<option value="' + c + '">' + display + '</option>';
});
}
}
function showCloseForm(tradeId) {
document.getElementById('closeForm').action = '/options/' + tradeId + '/close';
document.getElementById('closeFormOverlay').style.display = 'block';
document.getElementById('closeFormBox').style.display = 'block';
}
function hideCloseForm() {
document.getElementById('closeFormOverlay').style.display = 'none';
document.getElementById('closeFormBox').style.display = 'none';
}
</script>
{% endif %}
<div id="editFormOverlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.35);z-index:98;" onclick="hideEditForm()"></div>
<div id="editFormBox" style="display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:24px;z-index:99;width:400px;max-width:90vw;max-height:90vh;overflow-y:auto;">
<h3 style="margin-bottom:16px;">编辑记录</h3>
<form method="post" id="editForm">
<div class="form-group">
<label>合约</label>
<input type="text" name="contract_code" id="editContract" required style="font-size:0.9rem;">
</div>
<div class="form-group">
<label>类型</label>
<select name="option_type" id="editOptionType" required>
{% for v, label in option_types %}
<option value="{{ v }}">{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>买卖</label>
<select name="direction" id="editDirection" required>
{% for v, label in option_directions %}
<option value="{{ v }}">{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>行权价</label>
<input type="number" step="any" name="strike_price" id="editStrike" required style="font-size:0.9rem;">
</div>
<div class="form-group">
<label>开仓日期</label>
<input type="date" name="open_date" id="editOpenDate" required style="font-size:0.85rem;">
</div>
<div class="form-group">
<label>权利金</label>
<input type="number" step="any" name="open_price" id="editOpenPrice" required style="font-size:0.9rem;">
</div>
<div class="form-group">
<label>手续费</label>
<input type="number" step="any" name="open_fee" id="editOpenFee" value="0" style="font-size:0.9rem;">
</div>
<div id="editCloseFields" style="display:none;">
<div class="form-group">
<label>平仓日期</label>
<input type="date" name="close_date" id="editCloseDate" style="font-size:0.85rem;">
</div>
<div class="form-group">
<label>平仓价格</label>
<input type="number" step="any" name="close_price" id="editClosePrice" style="font-size:0.9rem;">
</div>
<div class="form-group">
<label>手续费</label>
<input type="number" step="any" name="close_fee" id="editCloseFee" value="0" style="font-size:0.9rem;">
</div>
</div>
<div style="display:flex;gap:10px;justify-content:flex-end;">
<button type="button" class="btn" style="background:var(--th-bg);color:var(--fg);" onclick="hideEditForm()">取消</button>
<button type="submit" class="btn btn-primary">保存</button>
</div>
</form>
</div>
<div id="pnlOverlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.35);z-index:99;" onclick="hidePnlDrawer()"></div>
<div id="pnlBox" style="display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:24px;z-index:100;width:380px;max-width:90vw;">
<h3 style="margin-bottom:4px;" id="pnlTitle"></h3>
<p style="font-size:0.8rem;color:var(--sub);margin-bottom:14px;" id="pnlFormula"></p>
<table class="calc-table" style="width:100%;border-collapse:collapse;font-size:0.84rem;margin-bottom:12px;" id="pnlTable"></table>
<div style="font-size:1rem;" id="pnlResult"></div>
<button style="position:absolute;top:12px;right:16px;background:none;border:none;font-size:1.3rem;cursor:pointer;color:var(--sub);" onclick="hidePnlDrawer()">&times;</button>
</div>
<script>
function showPnlDrawer(title, direction, openPrice, closePrice, openFee, closeFee, result) {
document.getElementById('pnlTitle').textContent = title;
var dirLabel = direction === 'sell' ? '卖' : '买';
document.getElementById('pnlFormula').textContent = '方向: ' + dirLabel + ' | 每点 20 元';
var diff = direction === 'sell' ? (openPrice - closePrice) : (closePrice - openPrice);
var gross = diff * 20;
var fees = openFee + closeFee;
var html = '<tr style="background:var(--th-bg);"><td>项目</td><td>计算</td><td style="text-align:right;">金额</td></tr>';
html += '<tr><td>价差</td><td>' + (direction === 'sell' ? openPrice + ' - ' + closePrice : closePrice + ' - ' + openPrice) + '</td><td style="text-align:right;">' + diff.toFixed(2) + ' 点</td></tr>';
html += '<tr><td>毛利</td><td>' + diff.toFixed(2) + ' × 20</td><td style="text-align:right;">' + gross.toFixed(2) + '</td></tr>';
html += '<tr><td>开仓手续费</td><td></td><td style="text-align:right;color:var(--danger-fg);">-' + openFee.toFixed(2) + '</td></tr>';
html += '<tr><td>平仓手续费</td><td></td><td style="text-align:right;color:var(--danger-fg);">-' + closeFee.toFixed(2) + '</td></tr>';
document.getElementById('pnlTable').innerHTML = html;
document.getElementById('pnlResult').innerHTML = '<b>盈亏 = ' + gross.toFixed(2) + ' - ' + openFee.toFixed(2) + ' - ' + closeFee.toFixed(2) + ' = <span style="color:' + (result > 0 ? 'var(--success-fg)' : result < 0 ? 'var(--danger-fg)' : 'var(--sub)') + ';">' + (result > 0 ? '+' : '') + result.toFixed(2) + '</span></b>';
document.getElementById('pnlOverlay').style.display = 'block';
document.getElementById('pnlBox').style.display = 'block';
}
function showEditForm(type, id, productCode, contractCode, optionType, direction, strike, openPrice, openFee, openDate, closePrice, closeFee, closeDate, status) {
document.getElementById('editForm').action = '/' + type + 's/' + id + '/edit';
document.getElementById('editContract').value = contractCode;
document.getElementById('editOptionType').value = optionType;
document.getElementById('editDirection').value = direction;
document.getElementById('editStrike').value = strike;
document.getElementById('editOpenDate').value = openDate;
document.getElementById('editOpenPrice').value = openPrice;
document.getElementById('editOpenFee').value = openFee || 0;
if (status === 'closed' && closeDate) {
document.getElementById('editCloseFields').style.display = 'block';
document.getElementById('editCloseDate').value = closeDate;
document.getElementById('editClosePrice').value = closePrice;
document.getElementById('editCloseFee').value = closeFee || 0;
} else {
document.getElementById('editCloseFields').style.display = 'none';
}
document.getElementById('editFormOverlay').style.display = 'block';
document.getElementById('editFormBox').style.display = 'block';
}
function hideEditForm() {
document.getElementById('editFormOverlay').style.display = 'none';
document.getElementById('editFormBox').style.display = 'none';
}
function hidePnlDrawer() {
document.getElementById('pnlOverlay').style.display = 'none';
document.getElementById('pnlBox').style.display = 'none';
}
</script>
<div id="confirmOverlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.35);z-index:199;" onclick="hideConfirm()"></div>
<div id="confirmBox" style="display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:28px 32px;z-index:200;text-align:center;max-width:90vw;">
<p id="confirmMsg" style="font-size:0.95rem;margin-bottom:20px;"></p>
<div style="display:flex;gap:10px;justify-content:center;">
<button class="btn" style="background:var(--th-bg);color:var(--fg);" onclick="hideConfirm()">取消</button>
<button class="btn" style="background:var(--danger);color:#fff;" id="confirmBtn">确认删除</button>
</div>
</div>
<script>
function confirmDelete(e, msg, url) {
e.preventDefault();
document.getElementById('confirmMsg').textContent = msg;
document.getElementById('confirmBtn').onclick = function() {
var f = document.createElement('form');
f.method = 'POST';
f.action = url;
document.body.appendChild(f);
f.submit();
};
document.getElementById('confirmOverlay').style.display = 'block';
document.getElementById('confirmBox').style.display = 'block';
}
function hideConfirm() {
document.getElementById('confirmOverlay').style.display = 'none';
document.getElementById('confirmBox').style.display = 'none';
}
</script>
{% endblock %}
+276
View File
@@ -0,0 +1,276 @@
{% 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 %}
{# ═══════════════ 新建轮次 ═══════════════ #}
<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:100px;">
<label>品种</label>
<select id="productSelect" required onchange="filterContracts()">
<option value="">--</option>
{% for pcode in product_contracts %}
<option value="{{ pcode }}">{{ pcode }}</option>
{% endfor %}
</select>
</div>
<div class="form-group" style="margin-bottom:0;flex:1;min-width:120px;">
<label>合约</label>
<select name="contract_code" id="contractSelect" required disabled>
<option value="">-- 先选品种 --</option>
</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>
{% if not active_rounds %}
<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>
{% endif %}
{% for round in active_rounds %}
{# ═══════════════ 轮次卡片 ═══════════════ #}
{% set locks = round.total_locks %}
<div style="margin-bottom:32px;border:1px solid var(--border);border-radius:10px;overflow:hidden;">
{# ── 轮次头部 ── #}
<div style="display:flex;align-items:center;gap:16px;padding:14px 20px;background:var(--th-bg);flex-wrap:wrap;">
<strong style="font-size:1rem;">{{ round.contract_code }}</strong>
<span class="badge badge-up">{{ round.daily_hands }} 手/天</span>
<span style="font-size:0.82rem;font-weight:600;{% if locks >= round.max_locks %}color:var(--danger-fg);{% endif %}">
锁仓 {{ locks }}/{{ round.max_locks }}
{% if locks >= round.max_locks %} ⚠ 熔断{% endif %}
</span>
<span style="margin-left:auto;display:flex;gap:8px;">
<button style="background:var(--surface);color:var(--fg);border:1px solid var(--border);padding:3px 12px;border-radius:4px;cursor:pointer;font-size:0.78rem;"
onclick="showMaxLocksModal({{ round.id }}, {{ round.max_locks }})">修改锁仓数</button>
<form method="post" action="/positions/{{ round.id }}/close" style="display:inline;">
<input type="hidden" name="result" value="profit_taken">
<button class="btn" style="background:var(--success);color:#fff;font-size:0.78rem;padding:4px 14px;" onclick="confirmDelete(event, '确认 {{ round.contract_code }} 止盈清仓?', this.closest('form').action)">止盈</button>
</form>
<form method="post" action="/positions/{{ round.id }}/close" style="display:inline;">
<input type="hidden" name="result" value="meltdown">
<button class="btn" style="background:var(--danger);color:#fff;font-size:0.78rem;padding:4px 14px;" onclick="confirmDelete(event, '确认 {{ round.contract_code }} 熔断清仓?', this.closest('form').action)">熔断</button>
</form>
<form method="post" action="/positions/{{ round.id }}/delete" style="display:inline;">
<button style="background:transparent;color:#dc2626;border:1px solid #ef4444;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.78rem;" onclick="confirmDelete(event, '确定删除 {{ round.contract_code }} 整轮数据?', this.closest('form').action)">删除</button>
</form>
</span>
</div>
<div style="padding:16px 20px;">
{# ── 止盈阈值 ── #}
{% if round.positions %}
{% set latest = round.positions[-1] %}
<div style="background:var(--accent-light);border:1px solid var(--accent);border-radius:6px;padding:10px 16px;margin-bottom:16px;display:flex;align-items:center;gap:20px;flex-wrap:wrap;">
<span style="font-size:0.78rem;color:var(--sub);">止盈阈值</span>
<span style="font-weight:700;">{{ round.daily_hands }} × {{ latest.amp_threshold }} = {{ round.daily_hands * latest.amp_threshold }} 点</span>
<span style="color:var(--sub);">|</span>
<span style="font-size:0.78rem;color:var(--sub);">当前 A = {{ latest.amp_threshold }}</span>
<span style="color:var(--sub);">|</span>
<span style="font-size:0.78rem;color:var(--sub);">总手数 {{ round.daily_hands * round.positions|length }}</span>
</div>
{% endif %}
{# ── 添加仓位 ── #}
<div style="margin-bottom:16px;">
<form method="post" action="/positions/{{ round.id }}/add">
<div style="display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap;">
<div class="form-group" style="margin-bottom:0;flex:1;min-width:100px;">
<label>开仓价</label>
<input type="number" name="open_price" required placeholder="如 1300" style="font-size:0.9rem;">
</div>
<div class="form-group" style="margin-bottom:0;flex:1;min-width:100px;">
<label>振幅 A</label>
<input type="number" name="amp_threshold" required placeholder="如 18" style="font-size:0.9rem;">
</div>
<div class="form-group" style="margin-bottom:0;flex:1;min-width:70px;">
<label>手数</label>
<input type="number" name="hands" value="{{ round.daily_hands }}" min="1" max="10" required style="font-size:0.9rem;">
</div>
<button type="submit" class="btn btn-primary" style="font-size:0.82rem;padding:8px 18px;">+ 添加</button>
</div>
</form>
</div>
{# ── 仓位表格 ── #}
{% if round.positions %}
<div class="table-wrap" style="margin-bottom:0;">
<table style="font-size:0.84rem;">
<tr>
<th>开仓价</th><th>A</th><th>锁仓价</th><th>手数</th><th>已锁</th><th>状态</th><th>操作</th>
</tr>
{% for p in round.positions|reverse %}
<tr>
<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:6px;justify-content:center;">
<button style="background:var(--warn-bg);color:var(--warn-fg);border:1px solid var(--warn);padding:2px 8px;border-radius:4px;cursor:pointer;font-size:0.75rem;font-weight:600;"
{% if p.locked_count >= p.hands %}disabled style="opacity:0.4;cursor:default;"{% endif %}
onclick="showLockConfirm({{ p.id }}, {{ p.lock_price }})">锁</button>
<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:2px 8px;border-radius:4px;cursor:pointer;font-size:0.75rem;"
{% 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:24px;color:var(--sub);font-size:0.84rem;">暂无仓位,请添加。</div>
{% endif %}
</div>
</div>
{% endfor %}
<script>
var productContracts = {{ product_contracts | tojson }};
function filterContracts() {
var prod = document.getElementById('productSelect').value;
var sel = document.getElementById('contractSelect');
sel.innerHTML = '<option value="">-- 选择合约 --</option>';
sel.disabled = !prod;
if (prod && productContracts[prod]) {
productContracts[prod].forEach(function(c) {
var display = c.startsWith(prod) ? c.substring(prod.length) : c;
sel.innerHTML += '<option value="' + c + '">' + display + '</option>';
});
}
}
function showMaxLocksModal(rid, currentMax) {
document.getElementById('maxLocksRid').value = rid;
document.getElementById('maxLocksInput').value = currentMax;
document.getElementById('maxLocksModal').style.display = 'block';
document.getElementById('maxLocksModalOverlay').style.display = 'block';
document.getElementById('maxLocksInput').focus();
}
function hideMaxLocksModal() {
document.getElementById('maxLocksModal').style.display = 'none';
document.getElementById('maxLocksModalOverlay').style.display = 'none';
}
function doUpdateMaxLocks() {
var rid = document.getElementById('maxLocksRid').value;
var fd = new FormData();
fd.append('max_locks', document.getElementById('maxLocksInput').value);
fetch('/positions/' + rid + '/max-locks', {method:'POST', body:fd})
.then(function() { location.reload(); });
}
function showLockConfirm(pid, lockPrice) {
document.getElementById('lockPid').value = pid;
document.getElementById('lockPriceInput').value = lockPrice;
document.getElementById('lockModal').style.display = 'block';
document.getElementById('lockModalOverlay').style.display = 'block';
document.getElementById('lockPriceInput').focus();
}
function hideLockModal() {
document.getElementById('lockModal').style.display = 'none';
document.getElementById('lockModalOverlay').style.display = 'none';
}
function doLock() {
var pid = document.getElementById('lockPid').value;
var fd = new FormData();
fd.append('lock_price', document.getElementById('lockPriceInput').value);
fetch('/positions/' + pid + '/lock', {method:'POST', body:fd})
.then(function() { location.reload(); });
}
</script>
<div id="maxLocksModalOverlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.35);z-index:159;" onclick="hideMaxLocksModal()"></div>
<div id="maxLocksModal" style="display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:24px;z-index:160;width:300px;max-width:90vw;">
<h3 style="margin-bottom:16px;">修改锁仓上限</h3>
<input type="hidden" id="maxLocksRid">
<div class="form-group">
<label>锁仓次数上限(熔断阈值)</label>
<input type="number" id="maxLocksInput" min="1" max="10" required style="font-size:1rem;text-align:center;font-weight:600;">
</div>
<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:8px;">
<button class="btn" style="background:var(--th-bg);color:var(--fg);" onclick="hideMaxLocksModal()">取消</button>
<button class="btn" style="background:var(--accent);color:#fff;" onclick="doUpdateMaxLocks()">确认修改</button>
</div>
</div>
<div id="lockModalOverlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.35);z-index:149;" onclick="hideLockModal()"></div>
<div id="lockModal" style="display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:24px;z-index:150;width:300px;max-width:90vw;">
<h3 style="margin-bottom:16px;">确认锁仓</h3>
<input type="hidden" id="lockPid">
<div class="form-group">
<label>锁仓价位</label>
<input type="number" id="lockPriceInput" required style="font-size:1rem;text-align:center;font-weight:600;color:var(--danger-fg);">
</div>
<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:8px;">
<button class="btn" style="background:var(--th-bg);color:var(--fg);" onclick="hideLockModal()">取消</button>
<button class="btn" style="background:var(--warn);color:#fff;" onclick="doLock()">确认锁仓</button>
</div>
</div>
<div id="confirmOverlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.35);z-index:199;" onclick="hideConfirm()"></div>
<div id="confirmBox" style="display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:28px 32px;z-index:200;text-align:center;max-width:90vw;">
<p id="confirmMsg" style="font-size:0.95rem;margin-bottom:20px;"></p>
<div style="display:flex;gap:10px;justify-content:center;">
<button class="btn" style="background:var(--th-bg);color:var(--fg);" onclick="hideConfirm()">取消</button>
<button class="btn" style="background:var(--danger);color:#fff;" id="confirmBtn">确认</button>
</div>
</div>
<script>
function confirmDelete(e, msg, url) {
e.preventDefault();
document.getElementById('confirmMsg').textContent = msg;
document.getElementById('confirmBtn').onclick = function() {
var f = document.createElement('form');
f.method = 'POST';
f.action = url;
document.body.appendChild(f);
f.submit();
};
document.getElementById('confirmOverlay').style.display = 'block';
document.getElementById('confirmBox').style.display = 'block';
}
function hideConfirm() {
document.getElementById('confirmOverlay').style.display = 'none';
document.getElementById('confirmBox').style.display = 'none';
}
</script>
{% endblock %}
+404
View File
@@ -0,0 +1,404 @@
{% extends "base.html" %}
{% block title %}交易记录{% endblock %}
{% block heading %}交易记录{% endblock %}
{% block breadcrumb %}开平仓记录{% endblock %}
{% block content %}
{% set view = request.query_params.get('view', '') %}
{% set product_tab = request.query_params.get('tab', '全部') %}
{% set product_codes = product_contracts.keys()|list %}
{% set grouped_open = {} %}
{% for t in open_trades %}
{% set _ = grouped_open.setdefault(t.product_code, []).append(t) %}
{% endfor %}
<style>
.tabs { display: flex; gap: 0; margin-bottom: 24px; border-bottom: 2px solid var(--border); }
.tab-btn {
padding: 10px 20px; border: none; background: none;
font-size: 0.88rem; font-weight: 500; color: var(--sub);
cursor: pointer; text-decoration: none; border-bottom: 2px solid transparent;
margin-bottom: -2px; transition: color .12s, border-color .12s;
}
.tab-btn:hover { color: var(--fg); }
.tab-btn.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
.sub-tabs { display: flex; gap: 6px; margin-bottom: 16px; flex-wrap: wrap; }
.sub-tab-btn {
padding: 4px 14px; border: 1px solid var(--border); background: var(--surface);
font-size: 0.78rem; color: var(--sub); cursor: pointer; text-decoration: none;
border-radius: 4px; transition: color .12s, border-color .12s;
}
.sub-tab-btn:hover { color: var(--fg); border-color: var(--accent); }
.sub-tab-btn.active { color: var(--accent); border-color: var(--accent); font-weight: 600; background: var(--accent-light); }
</style>
<div class="tabs">
<a href="/trades/" class="tab-btn{% if view != 'closed' %} active{% endif %}">📋 持仓</a>
<a href="/trades/?view=closed" class="tab-btn{% if view == 'closed' %} active{% endif %}">📊 已平仓</a>
</div>
{% if view != 'closed' %}
<div class="tab-panel active">
{# ── 新建开仓 ── #}
<div class="section-title">新建开仓</div>
<div class="form-card" style="margin-bottom:28px;max-width:100%;">
<form method="post" action="/trades/open">
<div style="display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap;">
<div class="form-group" style="margin-bottom:0;flex:0 0 auto;min-width:100px;">
<label>品种</label>
<select id="productSelect" required onchange="filterContracts()">
<option value="">--</option>
{% for pcode in product_contracts %}
<option value="{{ pcode }}">{{ pcode }}</option>
{% endfor %}
</select>
</div>
<div class="form-group" style="margin-bottom:0;flex:0 0 auto;min-width:120px;">
<label>合约</label>
<select name="contract_code" id="contractSelect" required disabled>
<option value="">-- 先选品种 --</option>
</select>
</div>
<div class="form-group" style="margin-bottom:0;flex:0 0 auto;min-width:70px;">
<label>方向</label>
<select name="direction" required>
{% for v, label in directions %}
<option value="{{ v }}">{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="form-group" style="margin-bottom:0;flex:1;min-width:140px;">
<label>开仓日期</label>
<input type="date" name="open_date" value="{{ today }}" required style="font-size:0.85rem;">
</div>
<div class="form-group" style="margin-bottom:0;flex:0 0 auto;min-width:80px;">
<label>开仓价</label>
<input type="number" step="any" name="open_price" required placeholder="1300" style="font-size:0.9rem;">
</div>
<div class="form-group" style="margin-bottom:0;flex:0 0 auto;min-width:80px;">
<label>手续费</label>
<input type="number" step="any" name="open_fee" value="0" style="font-size:0.9rem;">
</div>
<button type="submit" class="btn btn-primary" style="font-size:0.82rem;padding:8px 18px;">开仓</button>
</div>
</form>
</div>
{# ── 持仓列表 ── #}
<div class="section-title">持仓中 · {{ open_trades|length }} 笔</div>
<div class="sub-tabs">
<a href="/trades/?tab=全部" class="sub-tab-btn{% if product_tab == '全部' %} active{% endif %}">全部</a>
{% for pc in product_codes %}
{% set count = grouped_open.get(pc, [])|length %}
<a href="/trades/?tab={{ pc }}" class="sub-tab-btn{% if product_tab == pc %} active{% endif %}">{{ pc }}{% if count %} · {{ count }}{% endif %}</a>
{% endfor %}
</div>
{% if open_trades %}
{% for pc in product_codes %}
{% if product_tab == '全部' or product_tab == pc %}
{% set trades = grouped_open.get(pc, []) %}
{% if trades %}
<div class="table-wrap" style="margin-bottom:24px;">
{% if product_tab == '全部' %}
<div class="section-title" style="font-size:0.88rem;margin-bottom:0;padding:6px 12px;">{{ pc }} · {{ trades|length }} 笔</div>
{% endif %}
<table>
<tr><th>合约</th><th>方向</th><th>开仓日期</th><th>开仓价</th><th>手续费</th><th>操作</th></tr>
{% for t in trades %}
<tr>
<td><strong>{{ t.contract_code.replace(t.product_code, '', 1) }}</strong></td>
<td>
{% if t.direction == 'short' %}
<span class="badge badge-down"></span>
{% else %}
<span class="badge badge-up"></span>
{% endif %}
</td>
<td>{{ t.open_date }} {{ weekdays[t.open_date.weekday()] }}</td>
<td>{{ t.open_price }}</td>
<td>{{ t.open_fee or 0 }}</td>
<td>
<button style="background:var(--accent);color:#fff;border:none;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.78rem;"
onclick="showCloseForm({{ t.id }})">平仓</button>
<button style="background:var(--success-bg);color:var(--success-fg);border:1px solid var(--success);padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.78rem;margin-left:8px;"
onclick="showEditForm('trade', {{ t.id }}, '{{ t.product_code }}', '{{ t.contract_code }}', '{{ t.direction }}', {{ t.open_price }}, {{ t.open_fee or 0 }}, '{{ t.open_date }}', null, null, null, '{{ t.status }}')">编辑</button>
<form method="post" action="/trades/{{ t.id }}/delete" style="display:inline;margin-left:8px;">
<button style="background:transparent;color:#dc2626;border:1px solid #ef4444;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.78rem;" onclick="confirmDelete(event, '确定删除此记录?', this.closest('form').action)">删除</button>
</form>
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
{% endif %}
{% endfor %}
{% else %}
<div style="text-align:center;padding:40px;color:var(--sub);">暂无持仓</div>
{% endif %}
{# ── 平仓弹窗 ── #}
<div id="closeFormOverlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.35);z-index:99;" onclick="hideCloseForm()"></div>
<div id="closeFormBox" style="display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:24px;z-index:100;width:360px;max-width:90vw;">
<h3 style="margin-bottom:16px;">平仓</h3>
<form method="post" id="closeForm">
<div class="form-group">
<label>平仓日期</label>
<input type="date" name="close_date" value="{{ today }}" required style="font-size:0.85rem;">
</div>
<div class="form-group">
<label>平仓价格</label>
<input type="number" step="any" name="close_price" required style="font-size:0.9rem;">
</div>
<div class="form-group">
<label>手续费</label>
<input type="number" step="any" name="close_fee" value="0" style="font-size:0.9rem;">
</div>
<div style="display:flex;gap:10px;justify-content:flex-end;">
<button type="button" class="btn" style="background:var(--th-bg);color:var(--fg);" onclick="hideCloseForm()">取消</button>
<button type="submit" class="btn btn-primary">确认平仓</button>
</div>
</form>
</div>
</div>
{% else %}
<div class="tab-panel active">
{# ═══════════════ 已平仓 ═══════════════ #}
{% if closed_trades %}
<div class="section-title">已平仓 · {{ closed_trades|length }} 笔</div>
<div class="table-wrap">
<table>
<tr><th>品种</th><th>合约</th><th>方向</th><th>开仓</th><th>平仓</th><th>持仓</th><th>开仓价</th><th>平仓价</th><th>开仓费</th><th>平仓费</th><th>盈亏</th><th>操作</th></tr>
{% for t in closed_trades %}
<tr>
<td>{{ t.product_code }}</td>
<td><strong>{{ t.contract_code.replace(t.product_code, '', 1) }}</strong></td>
<td>
{% if t.direction == 'short' %}
<span class="badge badge-down"></span>
{% else %}
<span class="badge badge-up"></span>
{% endif %}
</td>
<td style="font-size:0.8rem;">{{ t.open_date }} {{ weekdays[t.open_date.weekday()] }}</td>
<td style="font-size:0.8rem;">{{ t.close_date }} {{ weekdays[t.close_date.weekday()] }}</td>
<td>{{ (t.close_date - t.open_date).days }}天</td>
<td>{{ t.open_price }}</td>
<td>{{ t.close_price }}</td>
<td>{{ t.open_fee or 0 }}</td>
<td>{{ t.close_fee or 0 }}</td>
<td>
{% set p = t.pnl %}
{% if p is not none %}
<span style="font-weight:700;{% if p > 0 %}color:var(--danger-fg);{% elif p < 0 %}color:var(--success-fg);{% else %}color:var(--sub);{% endif %}">
{% if p > 0 %}+{% endif %}{{ p }}
</span>
<span style="color:var(--sub);cursor:pointer;font-size:0.75rem;margin-left:4px;"
onclick="showPnlDrawer('{{ t.product_code }} {{ t.contract_code.replace(t.product_code, '', 1) }}', '{{ t.direction }}', {{ t.open_price }}, {{ t.close_price }}, {{ t.open_fee or 0 }}, {{ t.close_fee or 0 }}, {{ p }})" title="计算过程">&#9432;</span>
{% endif %}
</td>
<td>
<button style="background:var(--success-bg);color:var(--success-fg);border:1px solid var(--success);padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.78rem;margin-left:8px;"
onclick="showEditForm('trade', {{ t.id }}, '{{ t.product_code }}', '{{ t.contract_code }}', '{{ t.direction }}', {{ t.open_price }}, {{ t.open_fee or 0 }}, '{{ t.open_date }}', {{ t.close_price }}, {{ t.close_fee or 0 }}, '{{ t.close_date }}', '{{ t.status }}')">编辑</button>
<form method="post" action="/trades/{{ t.id }}/delete" style="display:inline;margin-left:8px;">
<button style="background:transparent;color:#dc2626;border:1px solid #ef4444;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.78rem;" onclick="confirmDelete(event, '确定删除此记录?', this.closest('form').action)">删除</button>
</form>
</td>
</tr>
{% endfor %}
</table>
</div>
{% 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.open_fee or 0) %}
{% set ns.total_close_fee = ns.total_close_fee + (t.close_fee or 0) %}
{% endfor %}
<div style="display:flex;gap:24px;margin-bottom:24px;padding:12px 18px;background:var(--surface);border:1px solid var(--border);border-radius:8px;font-size:0.88rem;flex-wrap:wrap;">
<span>盈亏合计 <b style="{% if ns.total_pnl > 0 %}color:var(--danger-fg);{% elif ns.total_pnl < 0 %}color:var(--success-fg);{% endif %}">{% if ns.total_pnl > 0 %}+{% endif %}{{ '%.2f'|format(ns.total_pnl) }}</b></span>
<span style="color:var(--sub);">|</span>
<span>开仓手续费 <b>{{ '%.2f'|format(ns.total_open_fee) }}</b></span>
<span style="color:var(--sub);">|</span>
<span>平仓手续费 <b>{{ '%.2f'|format(ns.total_close_fee) }}</b></span>
<span style="color:var(--sub);">|</span>
<span>手续费合计 <b>{{ '%.2f'|format(ns.total_open_fee + ns.total_close_fee) }}</b></span>
</div>
{% else %}
<div style="text-align:center;padding:60px;color:var(--sub);">暂无已平仓记录</div>
{% endif %}
</div>
{% endif %}
{% if view != 'closed' %}
<script>
var productContracts = {{ product_contracts | tojson }};
function filterContracts() {
var prod = document.getElementById('productSelect').value;
var sel = document.getElementById('contractSelect');
sel.innerHTML = '<option value="">-- 选择合约 --</option>';
sel.disabled = !prod;
if (prod && productContracts[prod]) {
productContracts[prod].forEach(function(c) {
var display = c.startsWith(prod) ? c.substring(prod.length) : c;
sel.innerHTML += '<option value="' + c + '">' + display + '</option>';
});
}
}
function showCloseForm(tradeId) {
document.getElementById('closeForm').action = '/trades/' + tradeId + '/close';
document.getElementById('closeFormOverlay').style.display = 'block';
document.getElementById('closeFormBox').style.display = 'block';
}
function hideCloseForm() {
document.getElementById('closeFormOverlay').style.display = 'none';
document.getElementById('closeFormBox').style.display = 'none';
}
</script>
{% endif %}
<div id="editFormOverlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.35);z-index:98;" onclick="hideEditForm()"></div>
<div id="editFormBox" style="display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:24px;z-index:99;width:400px;max-width:90vw;max-height:90vh;overflow-y:auto;">
<h3 style="margin-bottom:16px;">编辑记录</h3>
<form method="post" id="editForm">
<div class="form-group">
<label>合约</label>
<input type="text" name="contract_code" id="editContract" required style="font-size:0.9rem;">
</div>
<div class="form-group">
<label>方向</label>
<select name="direction" id="editDirection" required>
{% for v, label in directions %}
<option value="{{ v }}">{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>开仓日期</label>
<input type="date" name="open_date" id="editOpenDate" required style="font-size:0.85rem;">
</div>
<div class="form-group">
<label>开仓价</label>
<input type="number" step="any" name="open_price" id="editOpenPrice" required style="font-size:0.9rem;">
</div>
<div class="form-group">
<label>开仓手续费</label>
<input type="number" step="any" name="open_fee" id="editOpenFee" value="0" style="font-size:0.9rem;">
</div>
<div id="editCloseFields" style="display:none;">
<div class="form-group">
<label>平仓日期</label>
<input type="date" name="close_date" id="editCloseDate" style="font-size:0.85rem;">
</div>
<div class="form-group">
<label>平仓价格</label>
<input type="number" step="any" name="close_price" id="editClosePrice" style="font-size:0.9rem;">
</div>
<div class="form-group">
<label>平仓手续费</label>
<input type="number" step="any" name="close_fee" id="editCloseFee" value="0" style="font-size:0.9rem;">
</div>
</div>
<div style="display:flex;gap:10px;justify-content:flex-end;">
<button type="button" class="btn" style="background:var(--th-bg);color:var(--fg);" onclick="hideEditForm()">取消</button>
<button type="submit" class="btn btn-primary">保存</button>
</div>
</form>
</div>
<div id="pnlOverlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.35);z-index:99;" onclick="hidePnlDrawer()"></div>
<div id="pnlBox" style="display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:24px;z-index:100;width:380px;max-width:90vw;">
<h3 style="margin-bottom:4px;" id="pnlTitle"></h3>
<p style="font-size:0.8rem;color:var(--sub);margin-bottom:14px;" id="pnlFormula"></p>
<table class="calc-table" style="width:100%;border-collapse:collapse;font-size:0.84rem;margin-bottom:12px;" id="pnlTable"></table>
<div style="font-size:1rem;" id="pnlResult"></div>
<button style="position:absolute;top:12px;right:16px;background:none;border:none;font-size:1.3rem;cursor:pointer;color:var(--sub);" onclick="hidePnlDrawer()">&times;</button>
</div>
<script>
function showPnlDrawer(title, direction, openPrice, closePrice, openFee, closeFee, result) {
document.getElementById('pnlTitle').textContent = title;
var dirLabel = direction === 'short' ? '空' : '多';
document.getElementById('pnlFormula').textContent = '方向: ' + dirLabel + ' | 每点 20 元';
var diff = direction === 'short' ? (openPrice - closePrice) : (closePrice - openPrice);
var gross = diff * 20;
var fees = openFee + closeFee;
var html = '<tr style="background:var(--th-bg);"><td>项目</td><td>计算</td><td style="text-align:right;">金额</td></tr>';
html += '<tr><td>价差</td><td>' + (direction === 'short' ? openPrice + ' - ' + closePrice : closePrice + ' - ' + openPrice) + '</td><td style="text-align:right;">' + diff.toFixed(2) + ' 点</td></tr>';
html += '<tr><td>毛利</td><td>' + diff.toFixed(2) + ' × 20</td><td style="text-align:right;">' + gross.toFixed(2) + '</td></tr>';
html += '<tr><td>开仓手续费</td><td></td><td style="text-align:right;color:var(--danger-fg);">-' + openFee.toFixed(2) + '</td></tr>';
html += '<tr><td>平仓手续费</td><td></td><td style="text-align:right;color:var(--danger-fg);">-' + closeFee.toFixed(2) + '</td></tr>';
document.getElementById('pnlTable').innerHTML = html;
document.getElementById('pnlResult').innerHTML = '<b>盈亏 = ' + gross.toFixed(2) + ' - ' + openFee.toFixed(2) + ' - ' + closeFee.toFixed(2) + ' = <span style="color:' + (result > 0 ? 'var(--danger-fg)' : result < 0 ? 'var(--success-fg)' : 'var(--sub)') + ';">' + (result > 0 ? '+' : '') + result.toFixed(2) + '</span></b>';
document.getElementById('pnlOverlay').style.display = 'block';
document.getElementById('pnlBox').style.display = 'block';
}
function hidePnlDrawer() {
document.getElementById('pnlOverlay').style.display = 'none';
document.getElementById('pnlBox').style.display = 'none';
}
function showEditForm(type, id, productCode, contractCode, direction, openPrice, openFee, openDate, closePrice, closeFee, closeDate, status) {
document.getElementById('editForm').action = '/' + type + 's/' + id + '/edit';
document.getElementById('editContract').value = contractCode;
document.getElementById('editDirection').value = direction;
document.getElementById('editOpenDate').value = openDate;
document.getElementById('editOpenPrice').value = openPrice;
document.getElementById('editOpenFee').value = openFee || 0;
if (status === 'closed' && closeDate) {
document.getElementById('editCloseFields').style.display = 'block';
document.getElementById('editCloseDate').value = closeDate;
document.getElementById('editClosePrice').value = closePrice;
document.getElementById('editCloseFee').value = closeFee || 0;
} else {
document.getElementById('editCloseFields').style.display = 'none';
}
document.getElementById('editFormOverlay').style.display = 'block';
document.getElementById('editFormBox').style.display = 'block';
}
function hideEditForm() {
document.getElementById('editFormOverlay').style.display = 'none';
document.getElementById('editFormBox').style.display = 'none';
}
</script>
<div id="confirmOverlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.35);z-index:199;" onclick="hideConfirm()"></div>
<div id="confirmBox" style="display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:28px 32px;z-index:200;text-align:center;max-width:90vw;">
<p id="confirmMsg" style="font-size:0.95rem;margin-bottom:20px;"></p>
<div style="display:flex;gap:10px;justify-content:center;">
<button class="btn" style="background:var(--th-bg);color:var(--fg);" onclick="hideConfirm()">取消</button>
<button class="btn" style="background:var(--danger);color:#fff;" id="confirmBtn">确认删除</button>
</div>
</div>
<script>
function confirmDelete(e, msg, url) {
e.preventDefault();
document.getElementById('confirmMsg').textContent = msg;
document.getElementById('confirmBtn').onclick = function() {
var f = document.createElement('form');
f.method = 'POST';
f.action = url;
document.body.appendChild(f);
f.submit();
};
document.getElementById('confirmOverlay').style.display = 'block';
document.getElementById('confirmBox').style.display = 'block';
}
function hideConfirm() {
document.getElementById('confirmOverlay').style.display = 'none';
document.getElementById('confirmBox').style.display = 'none';
}
</script>
{% endblock %}
BIN
View File
Binary file not shown.
+2
View File
@@ -1,6 +1,8 @@
services:
app:
build: .
image: ft-app
container_name: app
ports:
- "8000:8000"
volumes: