ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

时区、延迟、部分成交:技能跑在真实盘口上的三个隐性成本

时区、延迟、部分成交:技能跑在真实盘口上的三个隐性成本

时区、延迟、部分成交:技能跑在真实盘口上的三个隐性成本

【免费下载链接】day-trader-skill11 Claude skills for day trading: scan thousands of charts, track breaking news, time every entry and exit, and replay every outcome before you risk a dollar.项目地址: https://gitcode.com/gh_mirrors/da/day-trader-skill

把交易逻辑写成 Claude Skill,最容易让人产生错觉的一点是:回测跑通、PASS打印出来、订单票据渲染得清清楚楚,就以为链路已经闭环了。但真实盘口不看你的逻辑有多漂亮,它只认三件事——你的「现在」是哪一刻、你的价格是哪一刻的、你手里到底有几股。

day-trader-skill 这个仓库有意思的地方在于,它没有回避这三件事。11 个技能全部用标准库 Python 写成,从trader-scanner的图表扫描一路走到trader-broker的订单票据,中间不依赖任何付费 API,也因此被迫把每一个工程细节都摊在源码里:纽约时区是手算的,K 线闭合是带秒级宽限的,成交回执是一条一条人工记账的。社区里讨论 AI 交易代理落地时反复提到的那几类风险——时区错位、部分成交误判、成交确认缺失、浮点精度误差——在这份代码里几乎都能找到对应的防御措施,也都能找到它主动放弃的那部分。

本文不谈策略,只谈把「技能」放上真实盘口时,那三笔容易被忽略的成本,以及作者用什么样的最小实现去给它们兜底。

一、时区:把「今天」钉在纽约,而不是钉在你的机器上

手写 DST,而不是相信系统时钟

项目里没有zoneinfo,没有pytz,也没有TZ=America/New_York的运行时约定。trader-entry/entry.py 里是这样算偏移的:

def eastern_offset(utc): """Hours from UTC for New York: -4 in daylight time, -5 otherwise. DST runs from 2:00 a.m. on the second Sunday of March to 2:00 a.m. on the first Sunday of November (US rule since 2007).""" y = utc.year start = datetime.datetime.combine(_nth_sunday(y, 3, 2), datetime.time(7, 0)) # 2:00 EST end = datetime.datetime.combine(_nth_sunday(y, 11, 1), datetime.time(6, 0)) # 2:00 EDT naive = utc.replace(tzinfo=None) return -4 if start <= naive < end else -5

注意07:00和06:00这两个魔数:它们不是纽约的墙上时间,而是 UTC。3 月第二个周日 2:00 EST 正好是 07:00Z,11 月第一个周日 2:00 EDT 正好是 06:00Z。用 UTC 做比较边界,就绕开了「本地时间在切换那小时会重复或缺失」的经典坑。

代价是这段函数在entry.py、check.py、alerts.py、exit.py、size.py、ticket.py 里各存在一份,六个工具互不 import。这不是疏忽,而是这个仓库刻意的设计取向:每个 skill 目录都能被单独拷进~/.claude/skills/,不依赖兄弟目录的相对路径。用复制换独立性,规则一旦改(比如美国真改了 DST 法案),就得六处一起改。

--now的语义:不带偏移量 = 纽约时间

这是整个项目里最容易被误用的一行约定,parse_now在六个工具中写法一致:

if dt.tzinfo is None: return eastern_to_utc(dt) # "2026-10-19 10:15" 被当作纽约 10:15 return (dt - dt.utcoffset()).replace(tzinfo=None) # "...T14:15:00Z" 是绝对时间

也就是说,一个在北京的用户敲下--now "2026-10-19 10:15",得到的是纽约 10:15,而不是自己墙上的 10:15。测试里把这条钉死了:

self.assertEqual(entry.parse_now("2026-10-19 10:15"), DT(2026, 10, 19, 14, 15)) self.assertEqual(entry.parse_now("2026-10-19T14:15:00Z"), DT(2026, 10, 19, 14, 15)) self.assertEqual(entry.parse_now("2026-10-19 10:15 ET"), DT(2026, 10, 19, 14, 15))

这个约定的好处是:所有配置项no_entries_before: "09:45"、flat_by: "15:55"和--now处在同一个坐标系里,人写下的数字和工具理解的数字永远一致。坏处同样明显:如果你习惯性把手表时间直接粘进--now,工具不会报错,它会安静地把你的「上午十点」解释成纽约上午十点——对北京用户来说,这是 12 小时的偏差,一个必然「已过期」的空信号。

幽灵信号的真正来源:bar 网格与墙上时间的错配

数据侧的时间戳是 Unix 秒,绝对、无歧义。真正的陷阱在于「哪根 bar 算数」。evaluate()做了三件事:

for b in chart["bars"]: if b["t"] % step: continue # Yahoo 的实时 tick(10:49:45)不是 bar et = to_eastern(utc_of(b["t"])) if et.date() != day or not (open_dt <= et < close_dt): continue slot = int((et - open_dt).total_seconds()) // step

第一行用「时间戳能否被 300 整除」筛掉 Yahoo 混在 K 线数组里的实时 tick。这不是洁癖:10:49:45 这个点如果被当成一根 5 分钟 bar,它的收盘价会被拿去和开盘区间高点比较,产生一个 15 秒之后就不存在的突破信号。测试里专门留了这个夹具——TSLA-2026-10-19-5m.json的最后一条就是1792421385(10:49:45),而_bars[-1]["slot"]必须是 14(10:40 那根)。

第二行把 bar 换算成纽约日期,再和day比对。这一步保证了「昨天下午的盘后 bar」不会混进今天的开盘区间。

第三行把 bar 归到「距离开盘多少个 5 分钟」的整数槽位上。用槽位而不是绝对时间去比较,等于把整个逻辑锚定在「开盘后第几个格子」,夏令时切换当天也不会有任何偏移。

配套的还有两个容易被忘掉的日历细节:NYSE_HOLIDAYS和NYSE_EARLY_CLOSE各自硬编码了 2026、2027 两年,遇到早收盘(下午 1 点)时,入场截止时间从 15:30 前移到 12:30:

def early_cutoff(day, t, close_dt): """15:30(收盘前 30 分钟)在 1:00 p.m. 早收盘日变成 12:30。""" at = datetime.datetime.combine(day, t) if at < close_dt: return at moved = at - (datetime.datetime.combine(day, CLOSE) - close_dt) return moved if moved.time() > OPEN else close_dt

flat_by的 15:55 同理前移到 12:55(alerts.py 的ring_time、exit.py 的resolve_flat_by各实现一次)。这类「按收盘时间等比前移」的处理,比写死「早收盘日 12:30」更稳,因为它把「收盘前 30 分钟」这个真实约束保住了。

顺带一个细节能看出作者的谨慎:告警状态里存的是 UTC 时间戳,判断「今天响过没有」时不能直接比日期字符串,必须先转纽约:

def _rang_today(stamp, today): utc = datetime.datetime.strptime(stamp[:19], "%Y-%m-%dT%H:%M:%S") return to_eastern(utc).date().isoformat() == today

否则美东 23:30 响的闹钟,换算成 UTC 已经是第二天,会被当成「今天没响过」再响一遍。

二、延迟:60 秒的数据追赶,和 15 分钟的报价保鲜期

一根 bar 从收盘到被承认,最多要等 60 秒

entry.py顶部有四个常数,几乎定义了整条链路的延迟预算:

PAUSE = 0.5 # 两次请求免费接口之间的间隔 BACKOFF = 5.0 # HTTP 429 后重试前的等待 GRACE = 2 # bar 收盘后,watch 等多久才去问 SETTLE = 60 # 一根已收盘的 bar 最多等 Yahoo 数据追上多久

一根 bar 在时钟上闭合,并不等于它已经可以被信任。Yahoo 的regularMarketTime可能还停在 10:49:45,而 10:45 那根 5 分钟 bar 要到 10:50 才真正结束。于是判定逻辑变成三个条件的或:

if dt is None or dt >= end_ts or slot < top or now_ts >= end_ts + SETTLE: bars.append(b) else: pending = b res["lag_note"] = ("Yahoo's last trade is %s, before the %s bar's close: it counts once Yahoo " "catches up (a minute at most)" % (...))

「Yahoo 最新成交时间已经越过这根 bar 的收盘时刻」「后面还有更新的 bar」「已经硬等满 60 秒」——满足任一条件才承认。第三条是兜底:如果数据源就是不更新,60 秒后也要让流程往前走,而不是无限挂起。

这 60 秒的意义在于:它是「信号成立」的下界,不是上界。测试里test_waits_for_yahoo_to_catch_up记录了三次请求的实际时刻:10:12:00、10:15:02、10:15:32——第一次在等,第二次 Yahoo 还差 30 秒,第三次才拿到 10:10 那根 bar 的收盘价 364.18。也就是说,10:15 的突破信号,到 10:15:32 才被工具确认。

再叠上轮询间隔。watch默认--every 30,每根 bar 收盘后 2 秒(GRACE)先问一次,没追上就每 30 秒再问;遇到 HTTP 429 还要先sleep(5)。对免费接口的礼貌(PAUSE = 0.5、query2失败退到query1)在这里全部变成了延迟。

工具自己也知道这件事,所以它把「迟到的信号」当成另一笔交易来报告:

if res.get("minutes_since", 0) > step and res.get("last"): out.append("That was %d minutes ago; the last close is $%.2f (%s bar). A late entry is a different " "trade: the price, the stop distance and the odds have moved." % (...)) ... if res.get("minutes_since", 0) > step: out.append("Nothing was placed.") else: out.append("This is the moment to get in, if you take the trade. Nothing was placed.")

超过一根 bar 的时长,措辞就从「就是现在」切换成「那是几分钟前」。

15 分钟:整条链路上唯一被量化的保鲜期

从entry.py check打印TRIGGERED,到用户真的在券商那边点下确认,中间至少还有六步:exit.py plan定止损止盈、timemachine.py run跑一万次重放、size.py --write定量、check.py过风控、ticket.py show出票、ticket.py approve记录那个「yes」,最后才是 Claude 的 Allow once 卡片和券商的place_stock_order。每一步都是一次工具调用,也是一次可能被打断的对话轮次。

这条链路上,唯一被写进代码的时效约束是FRESH_MINUTES = 15:

FRESH_MINUTES = 15 def fresh(sym): ... age = (now_utc - t).total_seconds() return prices[sym] if -60 <= age <= FRESH_MINUTES * 60 else None

允许-60秒的未来时间,是对机器间时钟偏移的容忍;超过 15 分钟的报价直接弃用,并在报告里说一句「the price in trader/prices.json is older than 15 minutes: not used」。

这条规则真正咬人的地方在于风险是按「计划入场价和新鲜报价中较高者」来量的:

cands = [(p, w) for p, w in ((plan_entry, "plan"), (live, "price")) if p] ref, ref_from = max(cands, key=lambda x: x[0])

用一句人话解释:10:15 写下的计划,不能拿 10:55 已经跑上去的行情来低估风险。仓库自带的例子把代价算得很清楚——10:15 触发价 364.18、止损 358.32,每股风险 5.86,8 股的最大亏损 46.88,刚好卡在 50 美元上限之内,PASS;到 10:52 报价 366.48,同一张票据每股风险变成 8.16,8 股就是 65.28,直接BLOCK,顺带 reward:risk 从 1.00 掉到 0.44:

BLOCK #1 buy TSLA 9 shares, entry max loss $52.74 (limit $50), reward:risk 1.00 (min 1) - loss: 9 shares x 5.86 risk a share = $52.74 at the stop, over your $50 max loss per trade

test_risk.py 里test_a_fresh_higher_price_is_the_entry校验的正是这条路径。换句话说,延迟不是「成交价差几个点」的问题,它会把一张合法的订单变成一张被自己风控拒绝的订单。而一次「被拒绝」,在对话式交互里往往意味着重新定量、重新出票、再问一次 yes——延迟被放大了一轮。

还有一处隐性的时间戳绑定:ticket.py 的risk_verdict要求风控结论必须是「今天」的:

if risk.get("date") != today: return None, "the risk check is from %s, not today; run check.py again" % risk.get("date")

跨过纽约午夜,昨天的PASS就作废。这条规则挡住了「拿隔夜结论下单」,但也意味着:如果你在 15:58 跑完风控、16:00 才凑齐那个 yes,票据会直接变成NOT READY。而 15:55 正是flat_by的默认值——设计上根本不给你留这段窗口。

三、成交状态:placed不等于filled,部分成交会把账本撕开

回执只有三个状态,而市场有五种

ticket.py result的参数是这样定义的:

p.add_argument("--status", required=True, choices=["placed", "denied", "failed"]) p.add_argument("--broker-id") p.add_argument("--filled-qty") p.add_argument("--filled-price")

placed、denied、failed。没有partially_filled,没有canceled,也没有replaced。这不是遗漏,而是作者对「这个工具该管什么」的划界:它不订阅 websocket 订单流,不轮询订单状态,只负责把「人看到的那个结果」抄进trader/state/broker.json。所有状态收敛由使用者完成。

于是placed之后的处理逻辑变成了一条窄门:

if a.status == "placed" and a.filled_qty and a.filled_price: apply_fill(a.portfolio, cfg, rec["symbol"], rec["side"], a.filled_qty, a.filled_price, today) elif a.status == "placed": print("When it fills, record it: ticket.py fill --symbol %s --side %s --qty Q --price P" ...)

两个条件同时成立才记账,否则只留一句提示。这里的隐患正好落在「部分成交」上:8 股的市价单先成交 3 股,如果你按--filled-qty 3 --filled-price 364.20记账,账本里会多出一条shares: 3的 lot,而券商那边仍挂着 5 股的剩余委托,外加一组按 8 股计算的止损/止盈腿。账本和真实持仓从这一刻开始分叉,且没有任何字段能标记「这是一次部分成交」。

分叉会在下一步立刻咬人。apply_fill的卖出分支是强校验的:

if pos is None or float(pos.get("shares", 0)) + 1e-9 < qty: raise TicketError("cannot record selling %s %s: the portfolio holds %s" % (_str_num(qty), symbol, pos.get("shares", 0) if pos else 0))

如果之后止损腿按 8 股成交,而你账本里只有 3 股,这条命令会直接失败。唯一的出路是手工补一笔买入、或者用ticket.py fill强行把差额记上——两样都要求人先意识到「我的账本是残的」。这正是社区文章里说的「部分成交误判」:错误不在代码,而在代码假设了成交是原子的。

括号单把问题放大了一倍

size.py写出的票据是 bracket:

{"symbol": "TSLA", "side": "buy", "qty": "8", "type": "market", "time_in_force": "day", "order_class": "bracket", "take_profit_limit_price": "370.04", "stop_loss_stop_price": "358.32"}

trader-broker/SKILL.md 把券商侧的行为写得很直白:入场成交后,止损和止盈是挂在券商那里的两张互相取消的订单,且都是 day 单,收盘即失效。这意味着「部分成交」不是一笔订单的局部问题,而是三张订单的乘数问题——入场剩 5 股在等,止损腿却按 8 股挂着。

flatten命令就是为这个局面准备的,它甚至把「先撤腿」写进了输出:

print(" 3. If the bracket's stop-loss and take-profit are still open, Alpaca holds these shares for them") print(" and rejects a new sell. Cancel them first: get_orders (status open, nested true), then") print(" cancel_order_by_id for each open leg (Needs approval). Then place_stock_order with the ticket.")

而平仓数量有两个来源,优先级很关键:

if a.qty is not None: qty = _f(a.qty) # 来自 get_open_position ... else: qty = sum(...) # 来自 trader/portfolio.json source = a.portfolio ... print("The qty comes from %s. Check it against get_open_position %s before you say yes." % (source, sym))

不传--qty时,默认读本地账本——也就是那个可能已经残缺的portfolio.json。工具没有替你选,只是把「这个数字不是权威来源」印在屏幕上。在真实盘口上,这句话必须被当成硬性要求读:券商的get_open_position是唯一权威,本地账本只是缓存。

精度:能算到分,但别指望它替你四舍五入

浮点误差在这份代码里被处理得相当克制。价位统一走Decimal与ROUND_FLOOR(多头止损向下取整,永远朝远离入场价的方向偏一点),风险额则用整数分计算:

risk_c = int(round(abs(ref - stop) * 100)) risk_ps = risk_c / 100.0 max_loss = round(shares * risk_ps, 2)

max_loss在 ticket.py 里也是同样的「先转成分,再乘股数」:

risk = abs(entry - stop) return round(q * int(round(risk * 100)) / 100.0, 2)

持仓股数用round(..., 6)保留六位,apply_fill的卖出分支对「卖超」留了1e-9的容差。这些细节加起来只说明一件事:这套代码在记账上足够严谨,但在对账上完全依赖人把券商回执抄回来。而部分成交恰恰是最容易抄错的那一类回执。

四、兜底:三层闸门与四道硬熔断的最小实现

闸门不是「确认弹窗」,是密码学意义上的票据绑定

trader-broker/SKILL.md 开篇就写了三道闸门,每单每次都要过:风控今天过了这一张票据、用户在对话里对这一张票据打了 yes、用户在 Claude 自己的权限卡片上点了 Allow once。

第一道闸门的技术核心是一个 16 位指纹:

ALPACA_FIELDS = ("symbol", "side", "qty", "notional", "type", "time_in_force", "limit_price", ...) def fingerprint(order): core = {k: order[k] for k in ALPACA_FIELDS if k in order and order[k] is not None} blob = json.dumps(core, sort_keys=True, separators=(",", ":")) return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:16]

只哈希真正会发给券商的字段,why和plan被排除在外——这两个字段是给人看的,改文案不该让票据失效。反过来,股数从 8 改成 9、止损从 358.32 改成 358.00,哈希立刻变,票据从READY掉回NOT READY:

this exact order was not in the last risk check (it changed or is new); run check.py again

test_an_edited_order_is_not_ready覆盖的就是这个场景。这比「弹个窗问一下」强得多:确认框可以被肌肉记忆点掉,指纹不能。

第二道闸门是parse_yes,它的严格程度超出多数人的预期:

YES_WORDS = ("yes", "y") BATCH_WORDS = {"all", "every", "everything", "rest", "remaining", "them", "both", "batch", "each"} DOUBT_WORDS = {"no", "not", "dont", "don't", "wait", "cancel", "stop", "but", "maybe", "later", "hold", "unless", "if"}

必须以 yes 开头;出现批量词直接拒绝("yes to all" 不算);出现任何犹豫词、条件词、问号也拒绝("yes if it is under 340" 不算);如果回复里出现数字,必须能和这张票据的某个金额(数量、限价、止损、止盈、最大亏损)对上。测试里把sure、ok、yep、send it、yes but make it 1000全部列为不通过。

第三道闸门不在代码里,在权限配置里——place_stock_order设为 Needs approval,期权、加密、close_all_positions、cancel_all_orders一律 Blocked。三道闸门里有两道在模型之外,这个比例是刻意的。

四道具名熔断

真正把「隐性成本」限制在可控范围内的,是check.py里那几条会直接BLOCK的规则:

熔断代码位置拦住的是什么
必须有止损require_stop,缺失时报stop: no stop裸奔的市价单
单笔最大亏损max_loss_per_trade_usd,入场参考价取「计划价与新鲜报价的较高者」行情跑掉后仍按旧计划定量
当日累计风险max_daily_loss_usd,按每笔满额最大亏损累加,赢了也算「今天还没亏所以还能再来一把」
交易窗口no_entries_before/no_entries_after,早收盘等比前移开盘前 15 分钟的假突破、收盘前的流动性陷阱

第四条的实现值得单独看,它不依赖任何外部日历服务:

close = EARLY if d.isoformat() in NYSE_EARLY_CLOSE else CLOSE if t < OPEN or now_et.time() >= close: return ["window: the market is closed (%s New York; the session is %s to %s)" ...] if close == EARLY and after is not None: after = _shift(after, 3)

_shift(after, 3)把 15:30 变成 12:30。窗口判定用的是now_et——由to_eastern(now_utc)算出,和机器的本地时区毫无关系。这一点在测试里有明确断言:

def test_the_window_uses_new_york_time_not_the_machine(self): self.assertEqual(check.to_eastern(at("2026-10-19 10:15")), datetime.datetime(2026, 10, 19, 10, 15)) self.assertEqual(at("2026-10-19 10:15"), datetime.datetime(2026, 10, 19, 14, 15)) self.assertEqual(at("2026-12-01 10:15"), datetime.datetime(2026, 12, 1, 15, 15))

最重要的一条:出口永不设闸

所有熔断都有一个统一的例外。check.py先判定订单是 entry 还是 exit——卖出数量不超过持仓就是 exit——然后:

if kind == "entry": reasons.extend(later) else: warns.extend("exit, not blocked: " + x for x in later)

同一个原因,在 entry 上是阻断理由,在 exit 上只是一句警告。测试构造了一个极端场景:账户被blocked、max_order_usd=100、当日亏损限额已满、订单数已满,15:58 分(已过 15:30 入场截止)提交一张平仓卖单,结果依然PASS:

res = check.check([self.SELL], c, portfolio=self.PF, state=full, now=at("2026-10-19 15:58")) self.assertEqual((r["verdict"], r["kind"]), ("PASS", "exit"), r["reasons"])

这是整套设计里我最认同的一处取舍:风控的目标是限制你「进场」,不是把你锁在仓里。一个在 15:58 因为「当日交易次数已满」而拒绝平仓的系统,比没有风控更危险。

配套的工程细节也都指向同一个方向:write_json一律写临时文件再os.replace(避免半截 JSON 让状态文件报废);broker.json只追加不覆盖,每条记录保留用户的原话reply;flatten会覆盖orders.json里的旧提案并明确告诉你「替换了 1 条」;never places orders from a scheduled or background task: nobody is there to say yes——这一条把「延迟」的一部分变成了主动放弃:宁可慢,不接受无人值守的下单。

结语:这三个成本,没有一个能被「更聪明的模型」消掉

时区问题的本质是坐标系不统一,解决办法只能是「所有输入都显式声明坐标系」——这个仓库选了「裸时间即纽约」,并要求人记住它。

延迟问题的本质是数据源不可控加剧了人工链路,解决办法只能是「把时效写成硬性约束」——SETTLE = 60、FRESH_MINUTES = 15、risk.date == today,三个数字就是三道防线。

部分成交问题的本质是「账本与真实持仓会分叉」,代码能做的是强校验(卖超即报错)、显式提示(用券商数量核对本地缓存)、以及给出撤腿路径(先cancel_order_by_id再平仓);分叉的发现与修复,仍然留给人。

所以这份代码真正的价值不在那 11 个技能,而在它把三个隐性成本写成了可读的常数、可测的断言和会打印在屏幕上的那几句警告。想让技能跑在真实盘口上,先把这三句话读懂:你的「现在」是纽约的现在;你的价格最多新鲜 15 分钟;你的持仓以券商为准,不以账本为准。

【免费下载链接】day-trader-skill11 Claude skills for day trading: scan thousands of charts, track breaking news, time every entry and exit, and replay every outcome before you risk a dollar.项目地址: https://gitcode.com/gh_mirrors/da/day-trader-skill

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进