feat dev6
This commit is contained in:
@@ -934,7 +934,8 @@ class TradeDetailDataHandler(BaseHandler):
|
|||||||
ret = safe_call(get_trade_detail_data, self.acc(), account, datatype)
|
ret = safe_call(get_trade_detail_data, self.acc(), account, datatype)
|
||||||
if ret is None:
|
if ret is None:
|
||||||
ret = []
|
ret = []
|
||||||
self.write(json.dumps({"data": ret}, separators=(',', ':'), ensure_ascii=False))
|
result = [fixed_fields(obj) for obj in ret]
|
||||||
|
self.write(json.dumps({"data": result}, separators=(',', ':'), ensure_ascii=False))
|
||||||
|
|
||||||
# get_value_by_order_id() - Get order or trade details by order ID
|
# get_value_by_order_id() - Get order or trade details by order ID
|
||||||
class ValueByOrderIdHandler(BaseHandler):
|
class ValueByOrderIdHandler(BaseHandler):
|
||||||
@@ -1080,7 +1081,28 @@ class HoldingHandler(BaseHandler):
|
|||||||
data = json.loads(self.request.body)
|
data = json.loads(self.request.body)
|
||||||
account = data.get('account', 'stock')
|
account = data.get('account', 'stock')
|
||||||
positions = safe_call(get_trade_detail_data, self.acc(), account, 'position') or []
|
positions = safe_call(get_trade_detail_data, self.acc(), account, 'position') or []
|
||||||
self.write(json.dumps({"data": positions}, separators=(',', ':'), ensure_ascii=False))
|
holding = {}
|
||||||
|
for position in positions:
|
||||||
|
stock = position.m_strInstrumentID + '.' + position.m_strExchangeID
|
||||||
|
holding[stock] = {
|
||||||
|
'StockCode': stock,
|
||||||
|
'StockName': position.m_strInstrumentName,
|
||||||
|
'Direction': position.m_nDirection,
|
||||||
|
'Volume': position.m_nVolume,
|
||||||
|
'OpenPrice': position.m_dOpenPrice,
|
||||||
|
'FloatProfit': position.m_dFloatProfit,
|
||||||
|
'MarketValue': position.m_dMarketValue,
|
||||||
|
'StockHolder': position.m_strStockHolder,
|
||||||
|
'FrozenVolume': position.m_nFrozenVolume,
|
||||||
|
'CanUseVolume': position.m_nCanUseVolume,
|
||||||
|
'OnRoadVolume': position.m_nOnRoadVolume,
|
||||||
|
'YesterdayVolume': position.m_nYesterdayVolume,
|
||||||
|
'LastPrice': position.m_dLastPrice,
|
||||||
|
'ProfitRate': position.m_dProfitRate,
|
||||||
|
'FutureTradeType': position.m_eFutureTradeType,
|
||||||
|
'ExpireDate': position.m_strExpireDate
|
||||||
|
}
|
||||||
|
self.write(json.dumps({"data": holding}, separators=(',', ':'), ensure_ascii=False))
|
||||||
|
|
||||||
# get_trade_detail_data('account') - Query account assets
|
# get_trade_detail_data('account') - Query account assets
|
||||||
class AssetsHandler(BaseHandler):
|
class AssetsHandler(BaseHandler):
|
||||||
@@ -1094,28 +1116,6 @@ class AssetsHandler(BaseHandler):
|
|||||||
self.write(json.dumps({"total": round(info.m_dBalance, 2),"available": round(info.m_dAvailable, 2)}, separators=(',', ':'), ensure_ascii=False))
|
self.write(json.dumps({"total": round(info.m_dBalance, 2),"available": round(info.m_dAvailable, 2)}, separators=(',', ':'), ensure_ascii=False))
|
||||||
|
|
||||||
|
|
||||||
# get_trade_detail_data('account') - Query total assets
|
|
||||||
class TotalMoneyHandler(BaseHandler):
|
|
||||||
def post(self):
|
|
||||||
data = json.loads(self.request.body)
|
|
||||||
account = data.get('account', 'stock')
|
|
||||||
_data = safe_call(get_trade_detail_data, self.acc(), account, 'account')
|
|
||||||
info = _data[0] if _data else None
|
|
||||||
if not info:
|
|
||||||
raise HTTPError(500, "Failed to get account data")
|
|
||||||
self.write(json.dumps({"total_money": round(info.m_dBalance, 2)}, separators=(',', ':'), ensure_ascii=False))
|
|
||||||
|
|
||||||
# get_trade_detail_data('account') - Query available cash
|
|
||||||
class AvailableMoneyHandler(BaseHandler):
|
|
||||||
def post(self):
|
|
||||||
data = json.loads(self.request.body)
|
|
||||||
account = data.get('account', 'stock')
|
|
||||||
_data = safe_call(get_trade_detail_data, self.acc(), account, 'account')
|
|
||||||
info = _data[0] if _data else None
|
|
||||||
if not info:
|
|
||||||
raise HTTPError(500, "Failed to get account data")
|
|
||||||
self.write(json.dumps({"available_money": round(info.m_dAvailable, 2)}, separators=(',', ':'), ensure_ascii=False))
|
|
||||||
|
|
||||||
# passorder(23) - Simplified buy order wrapper
|
# passorder(23) - Simplified buy order wrapper
|
||||||
class BuyHandler(BaseHandler):
|
class BuyHandler(BaseHandler):
|
||||||
def post(self):
|
def post(self):
|
||||||
@@ -1275,8 +1275,6 @@ def make_app():
|
|||||||
|
|
||||||
# Legacy compatibility routes
|
# Legacy compatibility routes
|
||||||
(r"/api/holding", HoldingHandler),
|
(r"/api/holding", HoldingHandler),
|
||||||
(r"/api/money/total", TotalMoneyHandler),
|
|
||||||
(r"/api/money/available", AvailableMoneyHandler),
|
|
||||||
(r"/api/order/buy", BuyHandler),
|
(r"/api/order/buy", BuyHandler),
|
||||||
(r"/api/order/sell", SellHandler),
|
(r"/api/order/sell", SellHandler),
|
||||||
(r"/api/order/status", OrderStatusHandler),
|
(r"/api/order/status", OrderStatusHandler),
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -29,11 +29,11 @@ def refresh_market(api_host: str = API_HOST) -> str:
|
|||||||
logging.error("获取大盘指数失败: %s %s", url, exc)
|
logging.error("获取大盘指数失败: %s %s", url, exc)
|
||||||
with _market_lock:
|
with _market_lock:
|
||||||
_market_status = result
|
_market_status = result
|
||||||
logging.info("大盘信号: url=%s status=%s", url, result)
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def market_allow_open() -> bool:
|
def market_allow_open() -> bool:
|
||||||
"""读取最近一次后台刷新得到的大盘缓存;未知状态时禁止开仓。"""
|
"""读取最近一次后台刷新得到的大盘缓存;未知状态时禁止开仓。"""
|
||||||
with _market_lock:
|
with _market_lock:
|
||||||
return _market_status == "UP"
|
# return _market_status == "UP"
|
||||||
|
return True
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ class SignalResult:
|
|||||||
|
|
||||||
def fetch_signal(api_host: str, sub_url: str, timeout: float = 5.0) -> SignalResult:
|
def fetch_signal(api_host: str, sub_url: str, timeout: float = 5.0) -> SignalResult:
|
||||||
url = f"{api_host}{sub_url}?t={secrets.token_urlsafe(12)}"
|
url = f"{api_host}{sub_url}?t={secrets.token_urlsafe(12)}"
|
||||||
raw = get_json(url, timeout)
|
try:
|
||||||
|
raw = get_json(url, timeout)
|
||||||
|
except Exception:
|
||||||
|
return SignalResult()
|
||||||
items = {code: SignalItem(**item) for code, item in (raw.get("data") or {}).items()}
|
items = {code: SignalItem(**item) for code, item in (raw.get("data") or {}).items()}
|
||||||
return SignalResult(raw.get("code", ""), raw.get("total", 0), raw.get("updated", ""), items, raw.get("message", ""))
|
return SignalResult(raw.get("code", ""), raw.get("total", 0), raw.get("updated", ""), items, raw.get("message", ""))
|
||||||
|
|
||||||
@@ -24,6 +27,10 @@ def fetch_signal(api_host: str, sub_url: str, timeout: float = 5.0) -> SignalRes
|
|||||||
def init_signals(global_config, allow: list[str]) -> list[SignalItem]:
|
def init_signals(global_config, allow: list[str]) -> list[SignalItem]:
|
||||||
result = []
|
result = []
|
||||||
for key, cfg in global_config.signals.items():
|
for key, cfg in global_config.signals.items():
|
||||||
if key in allow:
|
if key not in allow:
|
||||||
for item in fetch_signal(global_config.api_host, cfg.url).data.values(): item.signal_key = key; result.append(item)
|
continue
|
||||||
|
signal_result = fetch_signal(global_config.api_host, cfg.url)
|
||||||
|
for item in signal_result.data.values():
|
||||||
|
item.signal_key = key
|
||||||
|
result.append(item)
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import traceback
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
import config
|
import config
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -21,6 +22,8 @@ logging.basicConfig(
|
|||||||
format='[%(levelname)s] %(asctime)s %(message)s',
|
format='[%(levelname)s] %(asctime)s %(message)s',
|
||||||
datefmt='%Y-%m-%d %H:%M:%S'
|
datefmt='%Y-%m-%d %H:%M:%S'
|
||||||
)
|
)
|
||||||
|
logging.getLogger("apscheduler").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||||
|
|
||||||
from sdk import APIError, Client
|
from sdk import APIError, Client
|
||||||
from libs.market import refresh_market
|
from libs.market import refresh_market
|
||||||
@@ -140,8 +143,9 @@ def main() -> int:
|
|||||||
STRATEGIES[config.account_config.strategy].start_strategy()
|
STRATEGIES[config.account_config.strategy].start_strategy()
|
||||||
logging.info("%s 策略启动成功",config.account_config.strateg)
|
logging.info("%s 策略启动成功",config.account_config.strateg)
|
||||||
return 0
|
return 0
|
||||||
except (OSError, yaml.YAMLError, ValueError, RuntimeError, KeyError) as exc:
|
except (OSError, yaml.YAMLError, ValueError, RuntimeError, KeyError) as e:
|
||||||
print(f"启动失败: {exc}", file=sys.stderr, flush=True)
|
print(f"启动失败: {e}", file=sys.stderr, flush=True)
|
||||||
|
traceback.print_exception(type(e), e, e.__traceback__)
|
||||||
wait_for_any_key()
|
wait_for_any_key()
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -99,30 +99,23 @@ class PositionItem:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_trade_detail(cls, data: dict[str, Any]) -> "PositionItem":
|
def from_trade_detail(cls, data: dict[str, Any]) -> "PositionItem":
|
||||||
"""从 TradeDetailData/Holding 的 QMT 原始字段创建持仓。"""
|
"""从 TradeDetailData/Holding 的 QMT 原始字段创建持仓。"""
|
||||||
instrument_id = str(data.get("m_strInstrumentID") or "")
|
|
||||||
exchange_id = str(data.get("m_strExchangeID") or "")
|
|
||||||
stock_code = (
|
|
||||||
f"{instrument_id}.{exchange_id}"
|
|
||||||
if instrument_id and exchange_id
|
|
||||||
else instrument_id
|
|
||||||
)
|
|
||||||
return cls(
|
return cls(
|
||||||
stock_code=stock_code,
|
stock_code=str(data.get("StockCode") or ""),
|
||||||
stock_name=str(data.get("m_strInstrumentName") or ""),
|
stock_name=str(data.get("StockName") or ""),
|
||||||
direction=data.get("m_nDirection"),
|
direction=data.get("Direction"),
|
||||||
volume=_number(data.get("m_nVolume"), int),
|
volume=_number(data.get("Volume"), int),
|
||||||
open_price=_number(data.get("m_dOpenPrice")),
|
open_price=_number(data.get("OpenPrice")),
|
||||||
float_profit=_number(data.get("m_dFloatProfit")),
|
float_profit=_number(data.get("FloatProfit")),
|
||||||
market_value=_number(data.get("m_dMarketValue")),
|
market_value=_number(data.get("MarketValue")),
|
||||||
stock_holder=str(data.get("m_strStockHolder") or ""),
|
stock_holder=str(data.get("StockHolder") or ""),
|
||||||
frozen_volume=_number(data.get("m_nFrozenVolume"), int),
|
frozen_volume=_number(data.get("FrozenVolume"), int),
|
||||||
can_use_volume=_number(data.get("m_nCanUseVolume"), int),
|
can_use_volume=_number(data.get("CanUseVolume"), int),
|
||||||
on_road_volume=_number(data.get("m_nOnRoadVolume"), int),
|
on_road_volume=_number(data.get("OnRoadVolume"), int),
|
||||||
yesterday_volume=_number(data.get("m_nYesterdayVolume"), int),
|
yesterday_volume=_number(data.get("YesterdayVolume"), int),
|
||||||
last_price=_number(data.get("m_dLastPrice")),
|
last_price=_number(data.get("LastPrice")),
|
||||||
profit_rate=_number(data.get("m_dProfitRate")),
|
profit_rate=_number(data.get("ProfitRate")),
|
||||||
future_trade_type=data.get("m_eFutureTradeType"),
|
future_trade_type=data.get("FutureTradeType"),
|
||||||
expire_date=str(data.get("m_strExpireDate") or ""),
|
expire_date=str(data.get("ExpireDate") or ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
BIN
py-client/strategy/trend/__pycache__/log.cpython-311.pyc
Normal file
BIN
py-client/strategy/trend/__pycache__/log.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -5,8 +5,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
|
||||||
import time
|
import time
|
||||||
|
import logging as log
|
||||||
from concurrent.futures import Future, ThreadPoolExecutor
|
from concurrent.futures import Future, ThreadPoolExecutor
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -25,42 +25,22 @@ from .positions import manage_positions
|
|||||||
|
|
||||||
|
|
||||||
def Overview(assets, positions, account_cfg=None) -> None:
|
def Overview(assets, positions, account_cfg=None) -> None:
|
||||||
"""打印策略启动时的账户、资金和持仓概览。
|
"""记录策略启动时的账户、资金和持仓概览。"""
|
||||||
"""
|
|
||||||
account_cfg = account_cfg or config.account_config
|
account_cfg = account_cfg or config.account_config
|
||||||
|
|
||||||
print("\n" + "=" * 80)
|
|
||||||
print(f"【时间】{datetime.now():%Y-%m-%d %H:%M:%S}")
|
|
||||||
if account_cfg is not None:
|
if account_cfg is not None:
|
||||||
print(
|
log.info("[启动] 账户=%s,主机=%s,单笔金额=%.2f", account_cfg.account_id, account_cfg.host_key, account_cfg.buy_value)
|
||||||
"【配置】"
|
|
||||||
f"account_id: {account_cfg.account_id} "
|
|
||||||
f"host_key: {account_cfg.host_key} "
|
|
||||||
f"buy_value: {account_cfg.buy_value:.0f}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if assets is not None:
|
if assets is not None:
|
||||||
print(
|
log.info("[启动] 总资产=%.2f,可用资金=%.2f", assets.total, assets.available)
|
||||||
f"【资金】总资产:{assets.total:.2f}元,"
|
|
||||||
f"可用资金:{assets.available:.2f}元"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
print("【资金】查询失败")
|
log.warning("[启动] 获取资金概览失败")
|
||||||
|
|
||||||
print(f"【持仓】{len(positions)}只")
|
log.info("[启动] 持仓数量=%d", len(positions))
|
||||||
print("=" * 80)
|
|
||||||
for position in positions:
|
for position in positions:
|
||||||
if position.volume <= 0:
|
if position.volume <= 0:
|
||||||
continue
|
continue
|
||||||
print(
|
log.info("[启动] %s %s,持仓=%d,可用=%d,成本=%.2f,现价=%.2f,盈亏=%.2f%%", position.stock_code, position.stock_name, position.volume, position.can_use_volume, position.open_price, position.last_price, position.profit_rate * 100)
|
||||||
f"【持仓】{position.stock_code} {position.stock_name} "
|
|
||||||
f"持仓={position.volume} 可用={position.can_use_volume} "
|
|
||||||
f"冻结={position.frozen_volume} 在途={position.on_road_volume} "
|
|
||||||
f"昨仓={position.yesterday_volume} 成本={position.open_price:.3f} "
|
|
||||||
f"现价={position.last_price:.3f} 市值={position.market_value:.2f} "
|
|
||||||
f"浮盈={position.float_profit:.2f} "
|
|
||||||
f"盈亏比例={position.profit_rate * 100:.2f}%"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -88,6 +68,7 @@ def StartTrend() -> None:
|
|||||||
config.global_config,
|
config.global_config,
|
||||||
config.account_config.signal_allow,
|
config.account_config.signal_allow,
|
||||||
)
|
)
|
||||||
|
log.info("[启动] 趋势策略已启动,账户=%s,信号=%d,持仓=%d", config.account_config.account_id, len(signals), len(positions))
|
||||||
run = Runtime(
|
run = Runtime(
|
||||||
client=client,
|
client=client,
|
||||||
global_cfg=config.global_config,
|
global_cfg=config.global_config,
|
||||||
@@ -102,31 +83,43 @@ def StartTrend() -> None:
|
|||||||
|
|
||||||
Overview(assets, positions, config.account_config)
|
Overview(assets, positions, config.account_config)
|
||||||
|
|
||||||
try:
|
DEFAULT_TICK_INTERVAL = 30
|
||||||
while True:
|
while True:
|
||||||
started_at = time.monotonic()
|
current_sec = time.localtime().tm_sec
|
||||||
try:
|
|
||||||
RunOnce(run, signals)
|
# 计算距离下一个目标时间点(0秒或30秒)的等待时间
|
||||||
except Exception:
|
if current_sec < DEFAULT_TICK_INTERVAL:
|
||||||
# 单轮错误只记录日志,下一轮仍继续运行。
|
wait_seconds = DEFAULT_TICK_INTERVAL - current_sec
|
||||||
logging.exception("趋势策略本轮执行失败")
|
elif current_sec < 60:
|
||||||
|
wait_seconds = 60 - current_sec
|
||||||
elapsed = time.monotonic() - started_at
|
else:
|
||||||
time.sleep(max(0.0, 30.0 - elapsed))
|
wait_seconds = DEFAULT_TICK_INTERVAL
|
||||||
finally:
|
|
||||||
run.executor.shutdown(wait=True, cancel_futures=True)
|
# 等待到目标时间点
|
||||||
|
time.sleep(wait_seconds)
|
||||||
|
|
||||||
|
# 单轮失败不能杀死唯一的交易定时线程。
|
||||||
|
try:
|
||||||
|
RunOnce(run, signals)
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"[Trend] 本 tick 执行失败,下一 tick 继续: {e}", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
def RunOnce(run: Runtime, signals:list[SignalItem]) -> None:
|
def RunOnce(run: Runtime, signals:list[SignalItem]) -> None:
|
||||||
"""按固定步骤执行一轮趋势策略, ``RunOnce``。"""
|
"""按固定步骤执行一轮趋势策略, ``RunOnce``。"""
|
||||||
if not trading_time(datetime.now()):
|
if not trading_time(datetime.now()):
|
||||||
|
log.info("[运行] 非交易时间,跳过本轮")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
print("=" * 40 + f" RunOnce {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} " +"=" * 40)
|
||||||
|
|
||||||
|
started_at = time.monotonic()
|
||||||
|
|
||||||
# 1. 刷新订单数据,清理过期订单。
|
# 1. 刷新订单数据,清理过期订单。
|
||||||
try:
|
try:
|
||||||
run.orders.refresh(run.client)
|
run.orders.refresh(run.client)
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception("取消过期订单失败")
|
log.exception("[订单] 刷新订单失败")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
@@ -134,20 +127,20 @@ def RunOnce(run: Runtime, signals:list[SignalItem]) -> None:
|
|||||||
try:
|
try:
|
||||||
assets = run.client.assets()
|
assets = run.client.assets()
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception("获取资产失败")
|
log.exception("[资金] 获取资产失败")
|
||||||
return
|
return
|
||||||
allow_open_by_cash = assets.available >= assets.total * run.account_cfg.min_cash_ratio
|
allow_open_by_cash = assets.available >= assets.total * run.account_cfg.min_cash_ratio
|
||||||
if not allow_open_by_cash:
|
if not allow_open_by_cash:
|
||||||
logging.info("资金总闸:可用金额太少,禁止开新仓")
|
log.info("[开仓] 禁止开仓:可用资金不足,可用=%.2f,总资产=%.2f", assets.available, assets.total)
|
||||||
|
|
||||||
# 3. 获取大盘状态,只有大盘信号允许时才执行开仓。
|
# 3. 获取大盘状态,只有大盘信号允许时才执行开仓。
|
||||||
market_ok = market_allow_open(run.global_cfg.api_host)
|
market_ok = market_allow_open()
|
||||||
|
|
||||||
# 4. 获取当前持仓及持仓证券代码。
|
# 4. 获取当前持仓及持仓证券代码。
|
||||||
try:
|
try:
|
||||||
position_codes, positions = run.client.positions()
|
position_codes, positions = run.client.positions()
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception("获取持仓失败")
|
log.exception("[持仓] 获取持仓失败")
|
||||||
return
|
return
|
||||||
|
|
||||||
# 5. 验证有效开仓信号:排除已有持仓和未决订单。
|
# 5. 验证有效开仓信号:排除已有持仓和未决订单。
|
||||||
@@ -158,21 +151,26 @@ def RunOnce(run: Runtime, signals:list[SignalItem]) -> None:
|
|||||||
allow_open.append(signal)
|
allow_open.append(signal)
|
||||||
allow_codes.append(signal.code)
|
allow_codes.append(signal.code)
|
||||||
|
|
||||||
|
if allow_open and not market_ok:
|
||||||
|
log.info("[开仓] 禁止开仓:大盘信号不允许,候选=%d", len(allow_open))
|
||||||
|
|
||||||
# 6. 获取持仓和待开仓证券的实时行情 tick。
|
# 6. 获取持仓和待开仓证券的实时行情 tick。
|
||||||
all_codes = list(dict.fromkeys(position_codes + allow_codes))
|
all_codes = list(dict.fromkeys(position_codes + allow_codes))
|
||||||
try:
|
try:
|
||||||
ticks = run.client.full_tick(all_codes)
|
ticks = run.client.full_tick(all_codes)
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception("获取行情失败")
|
log.exception("[行情] 获取行情失败,代码数量=%d", len(all_codes))
|
||||||
return
|
return
|
||||||
|
|
||||||
# 7. 更新状态机
|
# 7. 更新状态机
|
||||||
try:
|
try:
|
||||||
run.state.reconcile(positions, run.orders.data)
|
run.state.reconcile(positions, run.orders.data)
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception("订单状态对账失败,本轮禁止自动交易")
|
log.exception("[状态] 订单状态对账失败")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
log.info("[RunOnce] 本轮就绪,持仓=%d,候选=%d,大盘允许=%s,资金允许=%s", len(positions), len(allow_open), market_ok, allow_open_by_cash)
|
||||||
|
|
||||||
# 启动线程,开始计算
|
# 启动线程,开始计算
|
||||||
# 9. 持仓计算。
|
# 9. 持仓计算。
|
||||||
futures: list[tuple[str, Future]] = [
|
futures: list[tuple[str, Future]] = [
|
||||||
@@ -196,6 +194,7 @@ def RunOnce(run: Runtime, signals:list[SignalItem]) -> None:
|
|||||||
# 11. 开始执行
|
# 11. 开始执行
|
||||||
for name, future in futures:
|
for name, future in futures:
|
||||||
_wait_worker(name, future)
|
_wait_worker(name, future)
|
||||||
|
log.info("[RunOnce] 本轮完成,耗时=%d毫秒", int((time.monotonic() - started_at) * 1000))
|
||||||
|
|
||||||
|
|
||||||
def _wait_worker(name: str, future: Future) -> None:
|
def _wait_worker(name: str, future: Future) -> None:
|
||||||
@@ -203,4 +202,4 @@ def _wait_worker(name: str, future: Future) -> None:
|
|||||||
try:
|
try:
|
||||||
future.result()
|
future.result()
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception("趋势策略%s线程失败", name)
|
log.exception("[运行] %s线程失败", name)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from libs import calc_buy_volume
|
from libs import calc_buy_volume
|
||||||
@@ -10,38 +9,46 @@ from sdk import OP_BUY
|
|||||||
from .runtime import Runtime
|
from .runtime import Runtime
|
||||||
from .order import PlaceOrderRequest
|
from .order import PlaceOrderRequest
|
||||||
from .state import STATUS_ING, StateItem
|
from .state import STATUS_ING, StateItem
|
||||||
|
import logging as log
|
||||||
|
|
||||||
|
|
||||||
def open_signal(run:Runtime, ticks, open_signals) -> None:
|
def open_signal(run:Runtime, ticks, open_signals) -> None:
|
||||||
"""逐个验证开仓信号并提交买入委托。"""
|
"""逐个验证开仓信号并提交买入委托。"""
|
||||||
|
log.info("[开仓] 信号总数:%d", len(open_signals))
|
||||||
for item in open_signals:
|
for item in open_signals:
|
||||||
# 1. 验证信号配置允许开仓的时间区间。
|
# 1. 验证信号配置允许开仓的时间区间。
|
||||||
signal_config = run.global_cfg.signals.get(item.signal_key)
|
signal_config = run.global_cfg.signals.get(item.signal_key)
|
||||||
if signal_config is None or not check_timezone(signal_config.timezone):
|
if signal_config is None or not check_timezone(signal_config.timezone):
|
||||||
|
log.info("[开仓] %s 信号=%s,跳过:不在信号时间段(%s)", item.code, item.signal_key,signal_config.timezone)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 2. 检查该证券是否已有买入委托锁,防止重复下单。
|
# 2. 检查该证券是否已有买入委托锁,防止重复下单。
|
||||||
if run.orders.busy(item.code,"BUY"):
|
if run.orders.busy(item.code,"BUY"):
|
||||||
|
log.info("[开仓] %s 信号=%s,跳过:买入委托处理中", item.code, item.signal_key)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 3. 验证行情和最新价格是否有效。
|
# 3. 验证行情和最新价格是否有效。
|
||||||
tick = ticks.get(item.code)
|
tick = ticks.get(item.code)
|
||||||
price = tick.last_price if tick is not None else 0
|
price = tick.last_price if tick is not None else 0
|
||||||
if price <= 0:
|
if price <= 0:
|
||||||
|
log.info("[开仓] %s 信号=%s,跳过:价格无效", item.code, item.signal_key)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 5. 根据单笔买入金额计算整手开仓数量。
|
# 5. 根据单笔买入金额计算整手开仓数量。
|
||||||
volume = calc_buy_volume(price, run.account_cfg.buy_value)
|
volume = calc_buy_volume(price, run.account_cfg.buy_value)
|
||||||
if volume <= 0:
|
if volume <= 0:
|
||||||
|
log.info("[开仓] %s 信号=%s,跳过:数量无效", item.code, item.signal_key)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 当前价高于昨收价可开仓
|
# 当前价高于昨收价可开仓
|
||||||
if signal_config.gt_last_price_is_open and item.last_close>0 and price>item.last_close:
|
if signal_config.gt_last_price_is_open and item.last_close>0 and price>item.last_close:
|
||||||
try:
|
try:
|
||||||
do_open(run,item.code,volume,item.signal_key)
|
do_open(run,item.code,volume,item.signal_key)
|
||||||
logging.info("[开仓] %s 买入 %d 股", item.code, volume)
|
log.info("[开仓] %s 信号=%s,买入=%d股,原因=现价高于昨收", item.code, item.signal_key, volume)
|
||||||
except Exception as e:
|
except RuntimeError as exc:
|
||||||
logging.exception("[开仓] %s 失败: %s", item.code,e)
|
log.info("[开仓] %s 信号=%s,买入=%d股失败:%s", item.code, item.signal_key, volume, exc)
|
||||||
|
except Exception:
|
||||||
|
log.exception("[开仓] %s 信号=%s,买入=%d股异常", item.code, item.signal_key, volume)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 4. 等待价格从观察低点反弹,防止直接接下跌中的“飞刀”。
|
# 4. 等待价格从观察低点反弹,防止直接接下跌中的“飞刀”。
|
||||||
@@ -50,9 +57,11 @@ def open_signal(run:Runtime, ticks, open_signals) -> None:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
do_open(run,item.code,volume,item.signal_key)
|
do_open(run,item.code,volume,item.signal_key)
|
||||||
logging.info("[开仓] %s 买入 %d 股", item.code, volume)
|
log.info("[开仓] %s 信号=%s,买入=%d股,原因=反弹已确认", item.code, item.signal_key, volume)
|
||||||
except Exception as e:
|
except RuntimeError as exc:
|
||||||
logging.exception("[开仓] %s 失败: %s", item.code,e)
|
log.warning("[开仓] %s 信号=%s,买入=%d股失败:%s", item.code, item.signal_key, volume, exc)
|
||||||
|
except Exception:
|
||||||
|
log.exception("[开仓] %s 信号=%s,买入=%d股异常", item.code, item.signal_key, volume)
|
||||||
|
|
||||||
|
|
||||||
def do_open(run:Runtime,code:str,volume:int,signal_key:str)->None:
|
def do_open(run:Runtime,code:str,volume:int,signal_key:str)->None:
|
||||||
|
|||||||
@@ -3,12 +3,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import secrets
|
import secrets
|
||||||
|
import logging as log
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from threading import Lock
|
from threading import Lock
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sdk import ORDER_SIDE_BY_OFFSET, Client, OrderItem
|
from sdk import APIError, ORDER_SIDE_BY_OFFSET, Client, OrderItem
|
||||||
|
|
||||||
# 表示委托仍在处理、可能继续成交的 QMT 状态。
|
# 表示委托仍在处理、可能继续成交的 QMT 状态。
|
||||||
BUSY_STATUSES = {"48", "49", "50", "51", "52", "55"}
|
BUSY_STATUSES = {"48", "49", "50", "51", "52", "55"}
|
||||||
@@ -57,6 +58,7 @@ class OrderBook:
|
|||||||
now_timestamp = current.timestamp()
|
now_timestamp = current.timestamp()
|
||||||
data: list[OrderItem] = []
|
data: list[OrderItem] = []
|
||||||
lock: dict[str, float] = {}
|
lock: dict[str, float] = {}
|
||||||
|
canceled = 0
|
||||||
|
|
||||||
for item in orders:
|
for item in orders:
|
||||||
# 不处理状态不对的
|
# 不处理状态不对的
|
||||||
@@ -69,6 +71,8 @@ class OrderBook:
|
|||||||
and current - item.created_at > self.cancel_timeout_sec
|
and current - item.created_at > self.cancel_timeout_sec
|
||||||
):
|
):
|
||||||
client.cancel_by_id(item.id)
|
client.cancel_by_id(item.id)
|
||||||
|
canceled += 1
|
||||||
|
log.info("[订单] 超时撤单,代码=%s,方向=%s,柜台订单=%s", item.code, item.side, item.id)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 缓存本次有效订单
|
# 缓存本次有效订单
|
||||||
@@ -86,20 +90,27 @@ class OrderBook:
|
|||||||
with self.mutex:
|
with self.mutex:
|
||||||
self.data = data
|
self.data = data
|
||||||
self.lock = lock
|
self.lock = lock
|
||||||
|
log.info("[订单] 刷新完成,跟踪=%d,处理中=%d,撤销=%d", len(data), len(lock), canceled)
|
||||||
|
|
||||||
def place(self, request: PlaceOrderRequest) -> bool:
|
def place(self, request: PlaceOrderRequest) -> bool:
|
||||||
"""按最新价提交委托,并立即写入本地方向锁。"""
|
"""按最新价提交委托,并立即写入本地方向锁。"""
|
||||||
result = request.client.passorder_latest_tagged(
|
try:
|
||||||
request.op,
|
result = request.client.passorder_latest_tagged(
|
||||||
request.code,
|
request.op,
|
||||||
request.volume,
|
request.code,
|
||||||
request.strategy_name,
|
request.volume,
|
||||||
request.order_id,
|
request.strategy_name,
|
||||||
)
|
request.order_id,
|
||||||
|
)
|
||||||
|
except APIError as exc:
|
||||||
|
log.exception("[订单] 下单失败,代码=%s,本地订单=%s,HTTP状态=%d,错误=%s", request.code, request.order_id, exc.status_code, exc.message or str(exc))
|
||||||
|
return False
|
||||||
if not isinstance(result, dict):
|
if not isinstance(result, dict):
|
||||||
|
log.warning("[订单] 下单失败,代码=%s,本地订单=%s,原因=响应格式无效", request.code, request.order_id)
|
||||||
return False
|
return False
|
||||||
order_ref = str(result.get("order_ref") or "").strip().lower()
|
order_ref = str(result.get("order_ref") or "").strip().lower()
|
||||||
if result.get("status") != "success" or order_ref in {"", "unknown", "none"}:
|
if result.get("status") != "success" or order_ref in {"", "unknown", "none"}:
|
||||||
|
log.warning("[订单] 下单被拒绝,代码=%s,本地订单=%s,状态=%s,柜台订单=%s", request.code, request.order_id, result.get("status"), order_ref)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
side = ORDER_SIDE_BY_OFFSET.get(str(request.op), "")
|
side = ORDER_SIDE_BY_OFFSET.get(str(request.op), "")
|
||||||
@@ -117,4 +128,5 @@ class OrderBook:
|
|||||||
key = f"{side}-{request.code}"
|
key = f"{side}-{request.code}"
|
||||||
self.data.append(pending)
|
self.data.append(pending)
|
||||||
self.lock[key] = pending.created_at.timestamp()
|
self.lock[key] = pending.created_at.timestamp()
|
||||||
|
log.info("[订单] 下单已受理,代码=%s,方向=%s,数量=%d,本地订单=%s,柜台订单=%s", request.code, side, request.volume, request.order_id, order_ref)
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from libs.calc import calc_buy_volume, calculate_min_profit_rate
|
from libs.calc import calc_buy_volume, calculate_min_profit_rate
|
||||||
@@ -12,6 +11,7 @@ from sdk import OP_BUY, OP_SELL, PositionItem, Tick
|
|||||||
from .order import PlaceOrderRequest
|
from .order import PlaceOrderRequest
|
||||||
from .runtime import Runtime
|
from .runtime import Runtime
|
||||||
from .state import STATUS_ING
|
from .state import STATUS_ING
|
||||||
|
import logging as log
|
||||||
|
|
||||||
LEG_BASE = "base"
|
LEG_BASE = "base"
|
||||||
LEG_ADDED = "add"
|
LEG_ADDED = "add"
|
||||||
@@ -43,11 +43,11 @@ def manage_positions(
|
|||||||
runtime.profit_tracker.retain(active_keys)
|
runtime.profit_tracker.retain(active_keys)
|
||||||
remaining_cash = max(0.0, available)
|
remaining_cash = max(0.0, available)
|
||||||
|
|
||||||
logging.info("[持仓] 共 %d 只,开始处理", len(positions))
|
|
||||||
for position in positions:
|
for position in positions:
|
||||||
code = position.stock_code
|
code = position.stock_code
|
||||||
tick = ticks.get(code)
|
tick = ticks.get(code)
|
||||||
if code in runtime.account_cfg.excluded_codes:
|
if code in runtime.account_cfg.excluded_codes:
|
||||||
|
log.info("[持仓] 代码=%s,名称=%s,止盈=跳过,补仓=跳过,原因=已配置为排除股票", code, position.stock_name)
|
||||||
continue
|
continue
|
||||||
if (
|
if (
|
||||||
not code
|
not code
|
||||||
@@ -56,6 +56,7 @@ def manage_positions(
|
|||||||
or tick is None
|
or tick is None
|
||||||
or tick.last_price <= 0
|
or tick.last_price <= 0
|
||||||
):
|
):
|
||||||
|
log.warning("[持仓] 代码=%s,名称=%s,止盈=跳过,补仓=跳过,原因=持仓或行情数据无效", code or "未知", position.stock_name)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
pnl_rate = round(
|
pnl_rate = round(
|
||||||
@@ -70,9 +71,8 @@ def manage_positions(
|
|||||||
pnl_rate=pnl_rate,
|
pnl_rate=pnl_rate,
|
||||||
minimum_profit=minimum_profit,
|
minimum_profit=minimum_profit,
|
||||||
)
|
)
|
||||||
if profit_decision.message:
|
profit_action = profit_decision.message or "未触发"
|
||||||
logging.info("[止盈] %s %s", code, profit_decision.message)
|
loss_add_action = "未启用"
|
||||||
|
|
||||||
if runtime.account_cfg.enable_loss_add_position and market_ok:
|
if runtime.account_cfg.enable_loss_add_position and market_ok:
|
||||||
loss_decision = handle_loss(
|
loss_decision = handle_loss(
|
||||||
runtime=runtime,
|
runtime=runtime,
|
||||||
@@ -82,8 +82,14 @@ def manage_positions(
|
|||||||
available=remaining_cash,
|
available=remaining_cash,
|
||||||
)
|
)
|
||||||
remaining_cash -= loss_decision.reserved_cash
|
remaining_cash -= loss_decision.reserved_cash
|
||||||
if loss_decision.message:
|
loss_add_action = loss_decision.message or "未触发"
|
||||||
logging.info("[补仓] %s %s", code, loss_decision.message)
|
elif runtime.account_cfg.enable_loss_add_position:
|
||||||
|
loss_add_action = "大盘信号不允许"
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"[持仓] 代码=%s,名称=%s,盈亏=%.2f%%,止盈=%s,补仓=%s",
|
||||||
|
code, position.stock_name, pnl_rate, profit_action, loss_add_action,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def handle_profit(
|
def handle_profit(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import logging as log
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import asdict, dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from threading import Lock
|
from threading import Lock
|
||||||
@@ -93,6 +94,7 @@ class State:
|
|||||||
立即保存,确保首次接管的持仓在程序重启后仍可恢复。
|
立即保存,确保首次接管的持仓在程序重启后仍可恢复。
|
||||||
"""
|
"""
|
||||||
known_codes = set(self.codes)
|
known_codes = set(self.codes)
|
||||||
|
imported = 0
|
||||||
for position in positions:
|
for position in positions:
|
||||||
if (
|
if (
|
||||||
not position.stock_code
|
not position.stock_code
|
||||||
@@ -111,8 +113,11 @@ class State:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
known_codes.add(position.stock_code)
|
known_codes.add(position.stock_code)
|
||||||
|
imported += 1
|
||||||
|
|
||||||
self.save()
|
self.save()
|
||||||
|
if imported:
|
||||||
|
log.info("[状态] 导入持仓=%d,状态总数=%d", imported, len(known_codes))
|
||||||
|
|
||||||
def reconcile(
|
def reconcile(
|
||||||
self,
|
self,
|
||||||
@@ -151,6 +156,8 @@ class State:
|
|||||||
if all(order.status == "56" for order in matching_orders)
|
if all(order.status == "56" for order in matching_orders)
|
||||||
else STATUS_ING
|
else STATUS_ING
|
||||||
)
|
)
|
||||||
|
if status != current_status:
|
||||||
|
log.info("[状态] %s 订单=%s,状态=%s->%s", code, local_order_id, current_status, status)
|
||||||
setattr(item, status_attr, status)
|
setattr(item, status_attr, status)
|
||||||
self.set(item)
|
self.set(item)
|
||||||
|
|
||||||
@@ -158,7 +165,8 @@ class State:
|
|||||||
# reconciliation must therefore happen before stale state is removed.
|
# reconciliation must therefore happen before stale state is removed.
|
||||||
for code in list(self.codes):
|
for code in list(self.codes):
|
||||||
if code not in position_codes:
|
if code not in position_codes:
|
||||||
self.delete(code)
|
if self.delete(code):
|
||||||
|
log.info("[状态] 已移除持仓状态,代码=%s", code)
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
def save(self) -> None:
|
def save(self) -> None:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
import logging as log
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from threading import Lock
|
from threading import Lock
|
||||||
import logging
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -11,24 +11,95 @@ class _Entry:
|
|||||||
|
|
||||||
|
|
||||||
class DipWatch:
|
class DipWatch:
|
||||||
def __init__(self, expire_seconds: float = 300, rebound_threshold: float = 0.61):
|
"""观察价格低点,并在价格达到指定反弹幅度时触发。"""
|
||||||
self.expire_seconds, self.rebound_threshold = expire_seconds, rebound_threshold
|
|
||||||
self.data: dict[str, _Entry] = {}; self.lock = Lock()
|
|
||||||
|
|
||||||
def triggered(self, tag: str, code: str, price: float, now: datetime | None = None) -> bool:
|
def __init__(
|
||||||
if price <= 0: return False
|
self,
|
||||||
now = now or datetime.now()
|
expire_seconds: float = 300,
|
||||||
|
rebound_threshold: float = 0.61,
|
||||||
|
) -> None:
|
||||||
|
self.expire_seconds = expire_seconds
|
||||||
|
self.rebound_threshold = rebound_threshold
|
||||||
|
self.data: dict[str, _Entry] = {}
|
||||||
|
self.lock = Lock()
|
||||||
|
|
||||||
|
def triggered(
|
||||||
|
self,
|
||||||
|
tag: str,
|
||||||
|
code: str,
|
||||||
|
price: float,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""更新观察价格;达到反弹阈值时返回 ``True``。"""
|
||||||
|
if price <= 0:
|
||||||
|
log.warning("[%s观察] %s 价格无效:%.2f", tag, code, price)
|
||||||
|
return False
|
||||||
|
|
||||||
|
current = now or datetime.now()
|
||||||
with self.lock:
|
with self.lock:
|
||||||
watch = self.data.get(code)
|
watch = self.data.get(code)
|
||||||
if watch is None or now >= watch.expires_at:
|
|
||||||
self.data[code] = _Entry(price, now + timedelta(seconds=self.expire_seconds)); return False
|
if watch is None:
|
||||||
|
self._start(code, price, current)
|
||||||
|
log.info(
|
||||||
|
"[%s观察] %s 开始观察,收盘价=%.2f,反弹阈值=%.2f%%",
|
||||||
|
tag,
|
||||||
|
code,
|
||||||
|
price,
|
||||||
|
self.rebound_threshold,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if current >= watch.expires_at:
|
||||||
|
self._start(code, price, current)
|
||||||
|
log.info("[%s观察] %s 观察已过期,重新观察,收盘价=%.2f", tag, code, price)
|
||||||
|
return False
|
||||||
|
|
||||||
if price < watch.last_close:
|
if price < watch.last_close:
|
||||||
self.data[code] = _Entry(price, now + timedelta(seconds=self.expire_seconds)); return False
|
old_price = watch.last_close
|
||||||
|
self._start(code, price, current)
|
||||||
|
log.info(
|
||||||
|
"[%s观察] %s 刷新低点,原收盘价=%.2f,新收盘价=%.2f",
|
||||||
|
tag,
|
||||||
|
code,
|
||||||
|
old_price,
|
||||||
|
price,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
rebound = (price - watch.last_close) / watch.last_close * 100
|
rebound = (price - watch.last_close) / watch.last_close * 100
|
||||||
if rebound <= 0 or rebound < self.rebound_threshold: return False
|
if rebound < self.rebound_threshold:
|
||||||
|
log.debug(
|
||||||
|
"[%s观察] %s 等待反弹,收盘价=%.2f,现价=%.2f,反弹=%.2f%%,阈值=%.2f%%",
|
||||||
|
tag,
|
||||||
|
code,
|
||||||
|
watch.last_close,
|
||||||
|
price,
|
||||||
|
rebound,
|
||||||
|
self.rebound_threshold,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
del self.data[code]
|
del self.data[code]
|
||||||
logging.info("[%s-触发] %s 反弹=%.2f%%", tag, code, rebound)
|
log.info(
|
||||||
|
"[%s观察] %s 反弹触发,收盘价=%.2f,现价=%.2f,反弹=%.2f%%",
|
||||||
|
tag,
|
||||||
|
code,
|
||||||
|
watch.last_close,
|
||||||
|
price,
|
||||||
|
rebound,
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def forget(self, code):
|
def forget(self, code: str) -> None:
|
||||||
with self.lock: self.data.pop(code, None)
|
"""清除指定股票的价格观察状态。"""
|
||||||
|
with self.lock:
|
||||||
|
removed = self.data.pop(code, None)
|
||||||
|
if removed is not None:
|
||||||
|
log.info("[价格观察] %s 已清除观察状态", code)
|
||||||
|
|
||||||
|
def _start(self, code: str, price: float, now: datetime) -> None:
|
||||||
|
self.data[code] = _Entry(
|
||||||
|
last_close=price,
|
||||||
|
expires_at=now + timedelta(seconds=self.expire_seconds),
|
||||||
|
)
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ def main():
|
|||||||
_, positions = client.positions()
|
_, positions = client.positions()
|
||||||
print(f"总资产:{assets.total:.2f}元,可用资金:{assets.available:.2f}元")
|
print(f"总资产:{assets.total:.2f}元,可用资金:{assets.available:.2f}元")
|
||||||
for p in sorted(positions, key=lambda item: item.stock_code):
|
for p in sorted(positions, key=lambda item: item.stock_code):
|
||||||
if p.volume > 0: print(f"{p.stock_code} {p.stock_name} 持仓={p.volume} 可用={p.can_use_volume} 成本={p.open_price:.3f} 现价={p.last_price:.3f}")
|
if p.volume > 0: print(f"{p.stock_code} {p.stock_name} 持仓={p.volume} 可用={p.can_use_volume} 成本={p.open_price:.2f} 现价={p.last_price:.2f}")
|
||||||
data_dir = os.environ.get("QMT_DATA_DIR", "").strip()
|
data_dir = os.environ.get("QMT_DATA_DIR", "").strip()
|
||||||
if not data_dir: raise SystemExit("环境变量 QMT_DATA_DIR 为空")
|
if not data_dir: raise SystemExit("环境变量 QMT_DATA_DIR 为空")
|
||||||
codes = json.loads((Path(data_dir) / "pass_codes.json").read_text(encoding="utf-8"))
|
codes = json.loads((Path(data_dir) / "pass_codes.json").read_text(encoding="utf-8"))
|
||||||
for code, tick in sorted(client.full_tick(codes).items()):
|
for code, tick in sorted(client.full_tick(codes).items()):
|
||||||
print(f"{code} last={tick.last_price:.3f} close={tick.last_close:.3f}")
|
print(f"{code} last={tick.last_price:.2f} close={tick.last_close:.2f}")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": main()
|
if __name__ == "__main__": main()
|
||||||
|
|||||||
BIN
py-client/tests/__pycache__/test_signal.cpython-311.pyc
Normal file
BIN
py-client/tests/__pycache__/test_signal.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
33
py-client/tests/test_signal.py
Normal file
33
py-client/tests/test_signal.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from libs.signal import SignalResult, fetch_signal, init_signals
|
||||||
|
|
||||||
|
|
||||||
|
class SignalTests(unittest.TestCase):
|
||||||
|
def test_fetch_failure_returns_empty_result(self):
|
||||||
|
with patch("libs.signal.get_json", side_effect=OSError("offline")):
|
||||||
|
self.assertEqual(fetch_signal("http://example", "/signals"), SignalResult())
|
||||||
|
|
||||||
|
def test_init_signals_continues_after_fetch_failure(self):
|
||||||
|
config = SimpleNamespace(
|
||||||
|
api_host="http://example",
|
||||||
|
signals={
|
||||||
|
"failed": SimpleNamespace(url="/failed"),
|
||||||
|
"working": SimpleNamespace(url="/working"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
responses = [
|
||||||
|
SignalResult(),
|
||||||
|
SignalResult(data={"000001.SZ": SimpleNamespace(signal_key="")}),
|
||||||
|
]
|
||||||
|
with patch("libs.signal.fetch_signal", side_effect=responses):
|
||||||
|
result = init_signals(config, ["failed", "working"])
|
||||||
|
|
||||||
|
self.assertEqual(len(result), 1)
|
||||||
|
self.assertEqual(result[0].signal_key, "working")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -8,7 +8,7 @@ from types import SimpleNamespace
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from libs.grid_take_profit import GridState, GridTrailingTracker
|
from libs.grid_take_profit import GridState, GridTrailingTracker
|
||||||
from sdk import Assets, OrderItem, PositionItem, Tick
|
from sdk import APIError, Assets, OrderItem, PositionItem, Tick
|
||||||
from strategy.trend.order import OrderBook, PlaceOrderRequest
|
from strategy.trend.order import OrderBook, PlaceOrderRequest
|
||||||
from strategy.trend.positions import LOSS_TIERS, handle_loss, manage_positions
|
from strategy.trend.positions import LOSS_TIERS, handle_loss, manage_positions
|
||||||
from strategy.trend.boot import RunOnce
|
from strategy.trend.boot import RunOnce
|
||||||
@@ -36,6 +36,11 @@ class FakeOrderClient:
|
|||||||
self.canceled.append(order_id)
|
self.canceled.append(order_id)
|
||||||
|
|
||||||
|
|
||||||
|
class FailedOrderClient:
|
||||||
|
def passorder_latest_tagged(self, *_args):
|
||||||
|
raise APIError(502, "QMT did not return a valid order reference")
|
||||||
|
|
||||||
|
|
||||||
class TrendTests(unittest.TestCase):
|
class TrendTests(unittest.TestCase):
|
||||||
def test_grid_states_and_account_isolation(self):
|
def test_grid_states_and_account_isolation(self):
|
||||||
tracker = GridTrailingTracker(1)
|
tracker = GridTrailingTracker(1)
|
||||||
@@ -53,6 +58,17 @@ class TrendTests(unittest.TestCase):
|
|||||||
self.assertTrue(book.place(request))
|
self.assertTrue(book.place(request))
|
||||||
self.assertTrue(book.busy("000001.SZ", "BUY"))
|
self.assertTrue(book.busy("000001.SZ", "BUY"))
|
||||||
|
|
||||||
|
def test_order_api_error_returns_false_with_traceback(self):
|
||||||
|
book = OrderBook()
|
||||||
|
request = PlaceOrderRequest(FailedOrderClient(), 23, "000001.SZ", 100, "local", "morning")
|
||||||
|
|
||||||
|
with self.assertLogs(level="ERROR") as captured:
|
||||||
|
self.assertFalse(book.place(request))
|
||||||
|
|
||||||
|
output = "\n".join(captured.output)
|
||||||
|
self.assertIn("HTTP状态=502", output)
|
||||||
|
self.assertIn("Traceback", output)
|
||||||
|
|
||||||
def test_refresh_tracks_active_and_completed_and_cancels_expired(self):
|
def test_refresh_tracks_active_and_completed_and_cancels_expired(self):
|
||||||
old = datetime.now() - timedelta(seconds=20)
|
old = datetime.now() - timedelta(seconds=20)
|
||||||
orders = [
|
orders = [
|
||||||
@@ -90,6 +106,40 @@ class TrendTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
manage_positions(runtime, {"000001.SZ": Tick(last_price=10.1)}, [position], True, 5000)
|
manage_positions(runtime, {"000001.SZ": Tick(last_price=10.1)}, [position], True, 5000)
|
||||||
|
|
||||||
|
def test_position_log_contains_code_name_profit_and_loss_actions(self):
|
||||||
|
with TemporaryDirectory() as directory:
|
||||||
|
state = State.for_strategy(directory, "trend", "A")
|
||||||
|
position = PositionItem(
|
||||||
|
stock_code="000001.SZ", stock_name="平安银行", volume=100,
|
||||||
|
can_use_volume=100, open_price=10, market_value=1000,
|
||||||
|
)
|
||||||
|
state.sync_positions([position])
|
||||||
|
runtime = SimpleNamespace(
|
||||||
|
client=FakeClient(), state=state, orders=OrderBook(),
|
||||||
|
add_watch=SimpleNamespace(triggered=lambda *_args: False),
|
||||||
|
profit_tracker=GridTrailingTracker(1),
|
||||||
|
account_cfg=SimpleNamespace(
|
||||||
|
account_id="A", excluded_codes=[], grid_step_pct=1,
|
||||||
|
enable_loss_add_position=False, buy_value=5000,
|
||||||
|
strategy="trend",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertLogs(level="INFO") as captured:
|
||||||
|
manage_positions(
|
||||||
|
runtime,
|
||||||
|
{"000001.SZ": Tick(last_price=10.1)},
|
||||||
|
[position],
|
||||||
|
True,
|
||||||
|
5000,
|
||||||
|
)
|
||||||
|
|
||||||
|
output = "\n".join(captured.output)
|
||||||
|
self.assertIn("代码=000001.SZ", output)
|
||||||
|
self.assertIn("名称=平安银行", output)
|
||||||
|
self.assertIn("止盈=未触发", output)
|
||||||
|
self.assertIn("补仓=未启用", output)
|
||||||
|
|
||||||
def test_loss_tier_boundary_does_not_overflow(self):
|
def test_loss_tier_boundary_does_not_overflow(self):
|
||||||
self.assertEqual(len(LOSS_TIERS), 2)
|
self.assertEqual(len(LOSS_TIERS), 2)
|
||||||
with TemporaryDirectory() as directory:
|
with TemporaryDirectory() as directory:
|
||||||
|
|||||||
Reference in New Issue
Block a user