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 @@