时区、延迟、部分成交技能跑在真实盘口上的三个隐性成本【免费下载链接】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也没有TZAmerica/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(tzinfoNone) return -4 if start naive end else -5注意07:00和06:00这两个魔数它们不是纽约的墙上时间而是 UTC。3 月第二个周日 2:00 EST 正好是 07:00Z11 月第一个周日 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(tzinfoNone) # ...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 的实时 tick10: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的最后一条就是179242138510:49:45而_bars[-1][slot]必须是 1410:40 那根。第二行把 bar 换算成纽约日期再和day比对。这一步保证了「昨天下午的盘后 bar」不会混进今天的开盘区间。第三行把 bar 归到「距离开盘多少个 5 分钟」的整数槽位上。用槽位而不是绝对时间去比较等于把整个逻辑锚定在「开盘后第几个格子」夏令时切换当天也不会有任何偏移。配套的还有两个容易被忘掉的日历细节NYSE_HOLIDAYS和NYSE_EARLY_CLOSE各自硬编码了 2026、2027 两年遇到早收盘下午 1 点时入场截止时间从 15:30 前移到 12:30def 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_dtflat_by的 15:55 同理前移到 12:55alerts.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] (Yahoos last trade is %s, before the %s bars 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 15FRESH_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, keylambda x: x[0])用一句人话解释10:15 写下的计划不能拿 10:55 已经跑上去的行情来低估风险。仓库自带的例子把代价算得很清楚——10:15 触发价 364.18、止损 358.32每股风险 5.868 股的最大亏损 46.88刚好卡在 50 美元上限之内PASS到 10:52 报价 366.48同一张票据每股风险变成 8.168 股就是 65.28直接BLOCK顺带 reward:risk 从 1.00 掉到 0.44BLOCK #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 tradetest_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, requiredTrue, 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 brackets 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_keysTrue, separators(,, :)) return hashlib.sha256(blob.encode(utf-8)).hexdigest()[:16]只哈希真正会发给券商的字段why和plan被排除在外——这两个字段是给人看的改文案不该让票据失效。反过来股数从 8 改成 9、止损从 358.32 改成 358.00哈希立刻变票据从READY掉回NOT READYthis exact order was not in the last risk check (it changed or is new); run check.py againtest_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, dont, 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_usd100、当日亏损限额已满、订单数已满15:58 分已过 15:30 入场截止提交一张平仓卖单结果依然PASSres check.check([self.SELL], c, portfolioself.PF, statefull, nowat(2026-10-19 15:58)) self.assertEqual((r[verdict], r[kind]), (PASS, exit), r[reasons])这是整套设计里我最认同的一处取舍风控的目标是限制你「进场」不是把你锁在仓里。一个在 15:58 因为「当日交易次数已满」而拒绝平仓的系统比没有风控更危险。配套的工程细节也都指向同一个方向write_json一律写临时文件再os.replace避免半截 JSON 让状态文件报废broker.json只追加不覆盖每条记录保留用户的原话replyflatten会覆盖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),仅供参考