diff --git a/app.py b/app.py index c93572a..b7a8e88 100644 --- a/app.py +++ b/app.py @@ -176,6 +176,7 @@ def init_db(): "ALTER TABLE key_monitors ADD COLUMN market_code TEXT", "ALTER TABLE trade_records ADD COLUMN market_code TEXT", "ALTER TABLE order_plans ADD COLUMN plan_date TEXT", + "ALTER TABLE order_plans ADD COLUMN decision_reason TEXT", "ALTER TABLE key_monitors ADD COLUMN status TEXT DEFAULT 'active'", "ALTER TABLE key_monitors ADD COLUMN archived_at TEXT", "ALTER TABLE review_records ADD COLUMN direction TEXT", @@ -188,6 +189,9 @@ def init_db(): "ALTER TABLE review_records ADD COLUMN initial_pnl REAL", "ALTER TABLE review_records ADD COLUMN actual_pnl REAL", "ALTER TABLE review_records ADD COLUMN is_emotion INTEGER DEFAULT 0", + "ALTER TABLE review_records ADD COLUMN symbol_name TEXT", + "ALTER TABLE review_records ADD COLUMN market_code TEXT", + "ALTER TABLE review_records ADD COLUMN sina_code TEXT", ] for sql in migrations: try: @@ -321,6 +325,7 @@ def check_order_plans(): status = r["status"] pid = r["id"] name = r["symbol_name"] or sym + reason = r["decision_reason"] if "decision_reason" in r.keys() and r["decision_reason"] else "—" # 计划状态:价格进入决策区间则激活并通知 if status == "planned": @@ -330,6 +335,7 @@ def check_order_plans(): f"【开单计划触发】{name} ({sym})\n" f"方向:{'做多' if direction == 'long' else '做空'}\n" f"决策区间:{zone_lower} ~ {zone_upper}\n" + f"决策理由:{reason}\n" f"当前价:{p}\n" f"止损:{stop_loss} 止盈:{take_profit}" ) @@ -535,13 +541,14 @@ def add_plan(): conn.execute( """INSERT INTO order_plans (symbol, symbol_name, market_code, sina_code, direction, - zone_upper, zone_lower, stop_loss, take_profit, plan_date) - VALUES (?,?,?,?,?,?,?,?,?,?)""", + zone_upper, zone_lower, stop_loss, take_profit, plan_date, decision_reason) + VALUES (?,?,?,?,?,?,?,?,?,?,?)""", ( symbol, symbol_name, market_code, sina_code, direction, float(d["zone_upper"]), float(d["zone_lower"]), float(d["stop_loss"]), float(d["take_profit"]), today_str(), + d.get("decision_reason", "").strip(), ), ) conn.commit() @@ -671,6 +678,14 @@ def add_review(): flash("请选择离场触发") return redirect(url_for("records")) + symbol = d.get("symbol", "").strip() + symbol_name = d.get("symbol_name", "").strip() + market_code = d.get("market_code", "").strip() + sina_code = d.get("sina_code", "").strip() + if not symbol or not market_code: + flash("请从下拉列表选择品种(同花顺合约代码)") + return redirect(url_for("records")) + screenshot = "" f = request.files.get("screenshot") if f and f.filename: @@ -705,7 +720,7 @@ def add_review(): if auto_kline and not screenshot: try: generated = generate_review_kline_chart( - symbol=d.get("symbol", "").strip(), + symbol=symbol, periods=[d.get("kline_period1", "15m"), d.get("kline_period2", "1h")], count=int(d.get("kline_count") or 300), cutoff_label=d.get("kline_cutoff", "平仓时间"), @@ -725,17 +740,18 @@ def add_review(): conn = get_db() conn.execute( """INSERT INTO review_records - (open_time, close_time, symbol, timeframe, direction, + (open_time, close_time, symbol, symbol_name, market_code, sina_code, + timeframe, direction, entry_price, stop_loss, take_profit, close_price, lots, holding_duration, initial_pnl, actual_pnl, pnl, open_type, expected_rr, actual_rr, exit_trigger, exit_supplement, watch_after_breakeven, new_position_while_occupied, screenshot, auto_kline, kline_period1, kline_period2, kline_count, kline_cutoff, behavior_tags, is_emotion, notes) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( open_time, close_time, - d.get("symbol", "").strip(), + symbol, symbol_name, market_code, sina_code, d.get("timeframe", "").strip(), direction, entry_price, stop_loss, take_profit, close_price, lots, diff --git a/static/js/symbol.js b/static/js/symbol.js index 100bded..3919478 100644 --- a/static/js/symbol.js +++ b/static/js/symbol.js @@ -1,4 +1,14 @@ (function () { + function formatSub(item) { + return '同花顺 ' + item.ths_code + + (item.market_code ? ' · ' + item.market_code : '') + + ' · ' + (item.exchange || ''); + } + + function formatInputLabel(item) { + return item.input_label || (item.name + ' ' + item.ths_code); + } + function initSymbolInput(wrapper) { const input = wrapper.querySelector('.symbol-input'); const hiddenThs = wrapper.querySelector('input[name="symbol"]'); @@ -8,19 +18,21 @@ const dropdown = wrapper.querySelector('.symbol-dropdown'); const selectedEl = wrapper.querySelector('.symbol-selected'); let timer = null; + let abortCtrl = null; + const cache = new Map(); function hideDropdown() { dropdown.classList.remove('show'); } function selectItem(item) { - input.value = item.name; + const label = formatInputLabel(item); + input.value = label; hiddenThs.value = item.ths_code; hiddenName.value = item.name; if (hiddenMarket) hiddenMarket.value = item.market_code || ''; if (hiddenSina) hiddenSina.value = item.sina_code || ''; - selectedEl.textContent = '同花顺: ' + item.ths_code + - (item.market_code ? ' (' + item.market_code + ')' : ''); + selectedEl.textContent = formatSub(item); hideDropdown(); } @@ -33,9 +45,7 @@ const div = document.createElement('div'); div.className = 'symbol-option'; div.innerHTML = item.display + - '
同花顺 ' + item.ths_code + - (item.market_code ? ' · ' + item.market_code : '') + - ' · ' + item.exchange + '
'; + '
' + formatSub(item) + '
'; div.addEventListener('mousedown', function (e) { e.preventDefault(); selectItem(item); @@ -46,6 +56,29 @@ dropdown.classList.add('show'); } + function search(q) { + if (cache.has(q)) { + renderItems(cache.get(q)); + return; + } + if (abortCtrl) { + abortCtrl.abort(); + } + abortCtrl = new AbortController(); + fetch('/api/symbols/search?q=' + encodeURIComponent(q), { + signal: abortCtrl.signal, + }) + .then(function (r) { return r.json(); }) + .then(function (items) { + cache.set(q, items); + renderItems(items); + }) + .catch(function (err) { + if (err && err.name === 'AbortError') return; + hideDropdown(); + }); + } + input.addEventListener('input', function () { hiddenThs.value = ''; hiddenName.value = ''; @@ -59,11 +92,8 @@ } clearTimeout(timer); timer = setTimeout(function () { - fetch('/api/symbols/search?q=' + encodeURIComponent(q)) - .then(function (r) { return r.json(); }) - .then(renderItems) - .catch(function () { hideDropdown(); }); - }, 300); + search(q); + }, 120); }); input.addEventListener('blur', function () { @@ -73,9 +103,7 @@ input.addEventListener('focus', function () { const q = input.value.trim(); if (q && !hiddenThs.value) { - fetch('/api/symbols/search?q=' + encodeURIComponent(q)) - .then(function (r) { return r.json(); }) - .then(renderItems); + search(q); } }); } diff --git a/symbols.py b/symbols.py index 96ca2d7..a8a19b8 100644 --- a/symbols.py +++ b/symbols.py @@ -3,7 +3,9 @@ 展示同花顺合约代码(ag2608);行情默认新浪,机构用户可通过环境变量启用同花顺 iFinD。 """ import re +import threading import time +from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import date from typing import Optional @@ -64,6 +66,9 @@ PRODUCTS = [ _MAIN_CACHE: dict[str, tuple[float, dict]] = {} _CACHE_TTL = 300 +_main_index_lock = threading.Lock() +_main_index: dict[str, dict] = {} +_main_index_ts = 0.0 def build_ths_code(product: dict, year: int, month: int) -> str: @@ -164,14 +169,16 @@ def ths_to_sina_code(ths_code: str) -> Optional[str]: def _make_symbol_item(product: dict, year: int, month: int, volume: float) -> dict: ths = build_ths_code(product, year, month) + name = product["name"] return { - "name": product["name"], + "name": name, "ths_code": ths, "market_code": build_ths_full_code(product, year, month), "sina_code": build_sina_code(product, year, month), "exchange": product["exchange"], "contract": f"主力 {ths}", - "display": f"{product['name']} 主力 {ths}", + "display": f"{name} 主力 {ths}", + "input_label": f"{name} {ths}", "volume": volume, } @@ -218,6 +225,7 @@ def resolve_main_contract(product: dict) -> Optional[dict]: "exchange": product["exchange"], "contract": f"主力连续 {ths_main}", "display": f"{product['name']} 主力连续 {ths_main}", + "input_label": f"{product['name']} {ths_main}", "volume": raw.get("volume", 0), } @@ -226,26 +234,110 @@ def resolve_main_contract(product: dict) -> Optional[dict]: return best +def _enrich_item(item: dict) -> dict: + out = dict(item) + if not out.get("input_label"): + out["input_label"] = f"{out.get('name', '')} {out.get('ths_code', '')}".strip() + return out + + +def refresh_main_index(): + """后台预热全部品种主力合约,搜索时只读本地缓存。""" + global _main_index, _main_index_ts + new_idx: dict[str, dict] = {} + with ThreadPoolExecutor(max_workers=10) as pool: + futures = {pool.submit(resolve_main_contract, p): p for p in PRODUCTS} + for fut in as_completed(futures): + product = futures[fut] + try: + main = fut.result() + if main: + new_idx[product["sina"]] = _enrich_item(main) + except Exception: + pass + with _main_index_lock: + _main_index = new_idx + _main_index_ts = time.time() + + +def _warm_loop(): + while True: + try: + refresh_main_index() + except Exception: + pass + time.sleep(_CACHE_TTL) + + +def _start_warm_thread(): + threading.Thread(target=_warm_loop, daemon=True).start() + + +def _stub_main_contract(product: dict) -> dict: + """缓存未就绪时的快速占位(当月合约),避免首次打开搜索为空。""" + today = date.today() + return _enrich_item(_make_symbol_item(product, today.year, today.month, 0)) + + +def _product_matches(product: dict, q_lower: str) -> bool: + name_lower = product["name"].lower() + if q_lower in name_lower: + return True + if len(q_lower) >= 2: + ths_lower = product["ths"].lower() + sina_lower = product["sina"].lower() + if q_lower in ths_lower or q_lower in sina_lower: + return True + return False + + +def _match_score(product: dict, q_lower: str) -> int: + name_lower = product["name"].lower() + if name_lower == q_lower: + return 200 + if name_lower.startswith(q_lower): + return 150 + if q_lower in name_lower: + return 100 + ths_lower = product["ths"].lower() + if ths_lower == q_lower: + return 90 + if ths_lower.startswith(q_lower): + return 70 + if product["sina"].lower() == q_lower: + return 80 + return 10 + + def search_symbols(query: str) -> list: - q = query.strip().lower() + q = query.strip() if not q: return [] - results = [] + q_lower = q.lower() + with _main_index_lock: + index = dict(_main_index) + index_ready = bool(index) + + scored: list[tuple[int, dict]] = [] for p in PRODUCTS: - name = p["name"] - if q not in name.lower() and q not in p["ths"].lower() and q not in p["sina"].lower(): + if not _product_matches(p, q_lower): continue - main = resolve_main_contract(p) + main = index.get(p["sina"]) + if not main and not index_ready: + main = _stub_main_contract(p) if main: - results.append(main) + scored.append((_match_score(p, q_lower), main)) + + scored.sort(key=lambda x: -x[0]) + results = [item for _, item in scored[:12]] if not results and len(q) >= 3: - codes = ths_to_codes(query.strip()) + codes = ths_to_codes(q) if codes: raw = fetch_raw_for_volume(codes["sina_code"]) - name = raw["name"] if raw else query.strip() - results.append({ + name = raw["name"] if raw else q + results.append(_enrich_item({ "name": name, "ths_code": codes["ths_code"], "market_code": codes["market_code"], @@ -254,9 +346,12 @@ def search_symbols(query: str) -> list: "contract": codes["ths_code"], "display": f"{name} ({codes['ths_code']})", "volume": raw.get("volume", 0) if raw else 0, - }) + })) - return results[:12] + return results + + +_start_warm_thread() def get_price(market_code: str, sina_code: str = "") -> Optional[float]: diff --git a/templates/plans.html b/templates/plans.html index f4fcb6b..e210203 100644 --- a/templates/plans.html +++ b/templates/plans.html @@ -10,7 +10,7 @@
- + @@ -23,10 +23,11 @@ - +
-
- +
+ +
@@ -44,7 +45,11 @@ {% if p.status == 'planned' %}待触发 {% else %}已激活{% endif %}
-
{{ p.zone_lower }}~{{ p.zone_upper }} 损{{ p.stop_loss }} 盈{{ p.take_profit }}
+
+ 区间{{ p.zone_lower }}~{{ p.zone_upper }} + {% if p.decision_reason %} · {{ p.decision_reason }}{% endif %} + · 损{{ p.stop_loss }} 盈{{ p.take_profit }} +
{% else %} @@ -65,7 +70,7 @@
- + {% for p in history %} @@ -73,6 +78,7 @@ + {% else %} - + {% endfor %}
日期品种方向区间状态
日期品种方向决策区间决策理由状态
{{ p.symbol_name or p.symbol }} {{ '多' if p.direction == 'long' else '空' }} {{ p.zone_lower }}~{{ p.zone_upper }}{{ p.decision_reason or '—' }} {% if p.status == 'closed' %}完成 {% elif p.status == 'expired' %}失效 @@ -80,7 +86,7 @@
暂无历史
暂无历史
diff --git a/templates/records.html b/templates/records.html index 325b26f..9001793 100644 --- a/templates/records.html +++ b/templates/records.html @@ -7,7 +7,15 @@
- +
+ + + + + +
+
+