diff --git a/LICENSE b/LICENSE
index d514f841..be3f7b28 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,21 +1,661 @@
-MIT License
-
-Copyright (c) 2025 Open Alice Contributors
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+.
diff --git a/README.md b/README.md
index d3339c67..6ba31613 100644
--- a/README.md
+++ b/README.md
@@ -17,10 +17,9 @@ Your one-person Wall Street. Alice is an AI trading agent that gives you your ow
## Features
- **Dual AI provider** — switch between Claude Code CLI and Vercel AI SDK at runtime, no restart needed
-- **Crypto trading** — CCXT-based execution (Bybit, OKX, Binance, etc.) with a git-like wallet (stage, commit, push)
-- **Securities trading** — Alpaca integration for US equities with the same wallet workflow
-- **Guard pipeline** — extensible pre-execution safety checks for both crypto and securities (max position size, max leverage, cooldown between trades)
-- **Market data** — OpenBB-powered equity, crypto, commodity, and currency data layers with symbol search and technical indicator calculator
+- **Unified trading** — multi-account architecture supporting CCXT (Bybit, OKX, Binance, etc.) and Alpaca (US equities) with a git-like workflow (stage, commit, push)
+- **Guard pipeline** — extensible pre-execution safety checks (max position size, cooldown between trades, symbol whitelist)
+- **Market data** — OpenBB-powered equity, crypto, commodity, and currency data layers with unified symbol search (`marketSearchForResearch`) and technical indicator calculator
- **Equity research** — company profiles, financial statements, ratios, analyst estimates, earnings calendar, insider trading, and market movers (top gainers, losers, most active)
- **News collector** — background RSS collection from configurable feeds with archive search tools (`globNews`/`grepNews`/`readNews`). Also captures OpenBB news API results via piggyback
- **Cognitive state** — persistent "brain" with frontal lobe memory, emotion tracking, and commit history
@@ -34,11 +33,11 @@ Your one-person Wall Street. Alice is an AI trading agent that gives you your ow
**Provider** — The AI backend that powers Alice. Claude Code (subprocess) or Vercel AI SDK (in-process). Switchable at runtime via `ai-provider.json`.
-**Extension** — A self-contained tool package registered in ToolCenter. Each extension owns its tools, state, and persistence. Examples: crypto-trading, brain, analysis-kit.
+**Extension** — A self-contained tool package registered in ToolCenter. Each extension owns its tools, state, and persistence. Examples: trading, brain, analysis-kit.
-**Wallet** — A git-like workflow for trading operations. You stage orders, commit with a message, then push to execute. Every commit gets an 8-char hash. Full history is reviewable via `walletLog` / `walletShow`.
+**Trading** — A git-like workflow for trading operations. You stage orders, commit with a message, then push to execute. Every commit gets an 8-char hash. Full history is reviewable via `tradingLog` / `tradingShow`.
-**Guard** — A pre-execution check that runs before every trading operation reaches the exchange. Guards enforce limits (max position size, max leverage, cooldown between trades) and can be configured per-asset.
+**Guard** — A pre-execution check that runs before every trading operation reaches the exchange. Guards enforce limits (max position size, cooldown between trades, symbol whitelist) and can be configured per-asset.
**Connector** — An external interface through which users interact with Alice. Built-in: Web UI, Telegram, MCP Ask. Connectors register with the ConnectorRegistry; delivery always goes to the channel of last interaction.
@@ -72,8 +71,7 @@ graph LR
subgraph Extensions
OBB[OpenBB Data]
AK[Analysis Kit]
- CT[Crypto Trading]
- ST[Securities Trading]
+ TR[Trading]
GD[Guards]
NC[News Collector]
BR[Brain]
@@ -102,10 +100,8 @@ graph LR
OBB --> AK
OBB --> NC
AK --> TC
- CT --> TC
- ST --> TC
- GD --> CT
- GD --> ST
+ TR --> TC
+ GD --> TR
NC --> TC
BR --> TC
BW --> TC
@@ -124,7 +120,7 @@ graph LR
**Core** — `Engine` is a thin facade that delegates to `AgentCenter`, which routes all calls (both stateless and session-aware) through `ProviderRouter`. `ToolCenter` is a centralized tool registry — extensions register tools there, and it exports them in Vercel AI SDK and MCP formats. `EventLog` provides persistent append-only event storage (JSONL) with real-time subscriptions and crash recovery. `ConnectorRegistry` tracks which channel the user last spoke through.
-**Extensions** — domain-specific tool sets registered in `ToolCenter`. Each extension owns its tools, state, and persistence. `Guards` enforce pre-execution safety checks (position size limits, leverage caps, trade cooldowns) on both crypto and securities operations. `NewsCollector` runs background RSS fetches and piggybacks OpenBB news calls into a persistent archive searchable by the agent.
+**Extensions** — domain-specific tool sets registered in `ToolCenter`. Each extension owns its tools, state, and persistence. `Guards` enforce pre-execution safety checks (position size limits, trade cooldowns, symbol whitelist) on all trading operations. `NewsCollector` runs background RSS fetches and piggybacks OpenBB news calls into a persistent archive searchable by the agent.
**Tasks** — scheduled background work. `CronEngine` manages jobs and fires `cron.fire` events into the EventLog on schedule; a listener picks them up, runs them through the AI engine, and delivers replies via the ConnectorRegistry. `Heartbeat` is a periodic health-check that uses a structured response protocol (HEARTBEAT_OK / CHAT_NO / CHAT_YES).
@@ -158,9 +154,7 @@ All config lives in `data/config/` as JSON files with Zod validation. Missing fi
**AI Provider** — The default provider is Claude Code (`claude -p` subprocess). To use the [Vercel AI SDK](https://sdk.vercel.ai/docs) instead (Anthropic, OpenAI, Google, etc.), switch `ai-provider.json` to `vercel-ai-sdk` and add your API key to `api-keys.json`.
-**Crypto Trading** — Powered by [CCXT](https://docs.ccxt.com/). Configure exchange and API keys in `crypto.json`. Any CCXT-supported exchange works (Bybit, OKX, Binance, etc.).
-
-**Securities Trading** — Powered by [Alpaca](https://alpaca.markets/). Configure broker and API keys in `securities.json`. Supports paper and live trading.
+**Trading** — Multi-account architecture. Crypto via [CCXT](https://docs.ccxt.com/) (Bybit, OKX, Binance, etc.) configured in `crypto.json`. US equities via [Alpaca](https://alpaca.markets/) configured in `securities.json`. Both use the same git-like trading workflow.
| File | Purpose |
|------|---------|
@@ -169,8 +163,8 @@ All config lives in `data/config/` as JSON files with Zod validation. Missing fi
| `agent.json` | Max agent steps, evolution mode toggle, Claude Code tool permissions |
| `ai-provider.json` | Active AI provider (`vercel-ai-sdk` or `claude-code`), switchable at runtime |
| `api-keys.json` | AI provider API keys (Anthropic, OpenAI, Google) — only needed for Vercel AI SDK mode |
-| `crypto.json` | Allowed symbols, CCXT exchange config + API keys, demo trading flag, guards |
-| `securities.json` | Allowed symbols, Alpaca broker config + API keys, paper trading flag, guards |
+| `crypto.json` | CCXT exchange config + API keys, allowed symbols, guards |
+| `securities.json` | Alpaca broker config + API keys, allowed symbols, guards |
| `connectors.json` | Web/MCP server ports, Telegram bot credentials + enable, MCP Ask enable |
| `openbb.json` | OpenBB API URL, per-asset-class data providers, provider API keys |
| `news-collector.json` | RSS feeds, fetch interval, retention period, OpenBB piggyback toggle |
@@ -209,13 +203,11 @@ src/
vercel-ai-sdk/ # Vercel AI SDK ToolLoopAgent wrapper
extension/
analysis-kit/ # Indicator calculator and market data tools
- equity/ # Equity search, fundamentals, and data adapter
- crypto/ # Crypto search and data adapter
- currency/ # Currency search and data adapter
+ equity/ # Equity fundamentals and data adapter
+ market/ # Unified symbol search across equity, crypto, currency
news/ # OpenBB news tools (world + company headlines)
news-collector/ # RSS collector, piggyback wrapper, archive search tools
- crypto-trading/ # CCXT integration, wallet, guard pipeline, tools
- securities-trading/ # Alpaca integration, wallet, guard pipeline, tools
+ trading/ # Unified multi-account trading (CCXT + Alpaca), guard pipeline, git-like commit history
thinking-kit/ # Reasoning and calculation tools
brain/ # Cognitive state (memory, emotion)
browser/ # Browser automation bridge (via OpenClaw)
@@ -244,8 +236,7 @@ data/
sessions/ # JSONL conversation histories
brain/ # Agent memory and emotion logs
cache/ # API response caches
- crypto-trading/ # Crypto wallet commit history
- securities-trading/ # Securities wallet commit history
+ trading/ # Trading commit history (per-account)
news-collector/ # Persistent news archive (JSONL)
cron/ # Cron job definitions (jobs.json)
event-log/ # Persistent event log (events.jsonl)
@@ -258,4 +249,4 @@ docs/ # Architecture documentation
## License
-[MIT](LICENSE)
+[AGPL-3.0](LICENSE)
diff --git a/package.json b/package.json
index d18f0497..270908b7 100644
--- a/package.json
+++ b/package.json
@@ -20,7 +20,7 @@
"file-driven"
],
"author": "Open Alice Contributors",
- "license": "MIT",
+ "license": "AGPL-3.0-only",
"repository": {
"type": "git",
"url": "git+https://github.com/TraderAlice/OpenAlice.git"
@@ -33,7 +33,7 @@
"@ai-sdk/openai": "^3.0.30",
"@alpacahq/alpaca-trade-api": "^3.1.3",
"@grammyjs/auto-retry": "^2.0.2",
- "@hono/node-server": "^1.19.9",
+ "@hono/node-server": "^1.19.11",
"@modelcontextprotocol/sdk": "^1.26.0",
"@sinclair/typebox": "0.34.48",
"ai": "^6.0.86",
@@ -44,7 +44,7 @@
"express": "^5.2.1",
"file-type": "^21.3.0",
"grammy": "^1.40.0",
- "hono": "^4.11.9",
+ "hono": "^4.12.5",
"json5": "^2.2.3",
"pino": "^10.3.1",
"playwright-core": "1.58.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index bc431bb2..c0a79b7e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -24,8 +24,8 @@ importers:
specifier: ^2.0.2
version: 2.0.2(grammy@1.40.0)
'@hono/node-server':
- specifier: ^1.19.9
- version: 1.19.9(hono@4.11.9)
+ specifier: ^1.19.11
+ version: 1.19.11(hono@4.12.5)
'@modelcontextprotocol/sdk':
specifier: ^1.26.0
version: 1.26.0(zod@4.3.6)
@@ -57,8 +57,8 @@ importers:
specifier: ^1.40.0
version: 1.40.0
hono:
- specifier: ^4.11.9
- version: 4.11.9
+ specifier: ^4.12.5
+ version: 4.12.5
json5:
specifier: ^2.2.3
version: 2.2.3
@@ -338,8 +338,8 @@ packages:
'@grammyjs/types@3.24.0':
resolution: {integrity: sha512-qQIEs4lN5WqUdr4aT8MeU6UFpMbGYAvcvYSW1A4OO1PABGJQHz/KLON6qvpf+5RxaNDQBxiY2k2otIhg/AG7RQ==}
- '@hono/node-server@1.19.9':
- resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==}
+ '@hono/node-server@1.19.11':
+ resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==}
engines: {node: '>=18.14.1'}
peerDependencies:
hono: ^4
@@ -1221,8 +1221,8 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
- hono@4.11.9:
- resolution: {integrity: sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==}
+ hono@4.12.5:
+ resolution: {integrity: sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==}
engines: {node: '>=16.9.0'}
http-errors@2.0.1:
@@ -2161,9 +2161,9 @@ snapshots:
'@grammyjs/types@3.24.0': {}
- '@hono/node-server@1.19.9(hono@4.11.9)':
+ '@hono/node-server@1.19.11(hono@4.12.5)':
dependencies:
- hono: 4.11.9
+ hono: 4.12.5
'@humanwhocodes/config-array@0.13.0':
dependencies:
@@ -2289,7 +2289,7 @@ snapshots:
'@modelcontextprotocol/sdk@1.26.0(zod@4.3.6)':
dependencies:
- '@hono/node-server': 1.19.9(hono@4.11.9)
+ '@hono/node-server': 1.19.11(hono@4.12.5)
ajv: 8.18.0
ajv-formats: 3.0.1(ajv@8.18.0)
content-type: 1.0.5
@@ -2299,7 +2299,7 @@ snapshots:
eventsource-parser: 3.0.6
express: 5.2.1
express-rate-limit: 8.2.1(express@5.2.1)
- hono: 4.11.9
+ hono: 4.12.5
jose: 6.1.3
json-schema-typed: 8.0.2
pkce-challenge: 5.0.1
@@ -3009,7 +3009,7 @@ snapshots:
dependencies:
function-bind: 1.1.2
- hono@4.11.9: {}
+ hono@4.12.5: {}
http-errors@2.0.1:
dependencies:
diff --git a/src/connectors/web/routes/crypto.ts b/src/connectors/web/routes/crypto.ts
deleted file mode 100644
index 0d4fb3cb..00000000
--- a/src/connectors/web/routes/crypto.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import { Hono } from 'hono'
-import type { EngineContext } from '../../../core/types.js'
-
-/** Crypto trading routes: reconnect + account/positions/orders/wallet data */
-export function createCryptoRoutes(ctx: EngineContext) {
- const app = new Hono()
-
- // ==================== Reconnect ====================
-
- app.post('/reconnect', async (c) => {
- if (!ctx.reconnectCrypto) return c.json({ success: false, error: 'Not available' }, 501)
- const result = await ctx.reconnectCrypto()
- return c.json(result, result.success ? 200 : 500)
- })
-
- // ==================== Account & Positions ====================
-
- app.get('/account', async (c) => {
- const engine = ctx.getCryptoEngine?.()
- if (!engine) return c.json({ error: 'Crypto engine not connected' }, 503)
- try {
- const account = await engine.getAccount()
- return c.json(account)
- } catch (err) {
- return c.json({ error: String(err) }, 500)
- }
- })
-
- app.get('/positions', async (c) => {
- const engine = ctx.getCryptoEngine?.()
- if (!engine) return c.json({ error: 'Crypto engine not connected' }, 503)
- try {
- const positions = await engine.getPositions()
- return c.json({ positions })
- } catch (err) {
- return c.json({ error: String(err) }, 500)
- }
- })
-
- app.get('/orders', async (c) => {
- const engine = ctx.getCryptoEngine?.()
- if (!engine) return c.json({ error: 'Crypto engine not connected' }, 503)
- try {
- const orders = await engine.getOrders()
- return c.json({ orders })
- } catch (err) {
- return c.json({ error: String(err) }, 500)
- }
- })
-
- // ==================== Wallet (trade decision history) ====================
-
- app.get('/wallet/log', (c) => {
- const wallet = ctx.getCryptoWallet?.()
- if (!wallet) return c.json({ error: 'Crypto wallet not available' }, 503)
- const limit = Number(c.req.query('limit')) || 20
- const symbol = c.req.query('symbol') || undefined
- return c.json({ commits: wallet.log({ limit, symbol }) })
- })
-
- app.get('/wallet/show/:hash', (c) => {
- const wallet = ctx.getCryptoWallet?.()
- if (!wallet) return c.json({ error: 'Crypto wallet not available' }, 503)
- const hash = c.req.param('hash')
- const commit = wallet.show(hash)
- if (!commit) return c.json({ error: 'Commit not found' }, 404)
- return c.json(commit)
- })
-
- app.get('/wallet/status', (c) => {
- const wallet = ctx.getCryptoWallet?.()
- if (!wallet) return c.json({ error: 'Crypto wallet not available' }, 503)
- return c.json(wallet.status())
- })
-
- return app
-}
diff --git a/src/connectors/web/routes/securities.ts b/src/connectors/web/routes/securities.ts
deleted file mode 100644
index 217fe3be..00000000
--- a/src/connectors/web/routes/securities.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-import { Hono } from 'hono'
-import type { EngineContext } from '../../../core/types.js'
-
-/** Securities trading routes: reconnect + account/portfolio/orders/wallet/market data */
-export function createSecuritiesRoutes(ctx: EngineContext) {
- const app = new Hono()
-
- // ==================== Reconnect ====================
-
- app.post('/reconnect', async (c) => {
- if (!ctx.reconnectSecurities) return c.json({ success: false, error: 'Not available' }, 501)
- const result = await ctx.reconnectSecurities()
- return c.json(result, result.success ? 200 : 500)
- })
-
- // ==================== Account & Portfolio ====================
-
- app.get('/account', async (c) => {
- const engine = ctx.getSecuritiesEngine?.()
- if (!engine) return c.json({ error: 'Securities engine not connected' }, 503)
- try {
- const account = await engine.getAccount()
- return c.json(account)
- } catch (err) {
- return c.json({ error: String(err) }, 500)
- }
- })
-
- app.get('/portfolio', async (c) => {
- const engine = ctx.getSecuritiesEngine?.()
- if (!engine) return c.json({ error: 'Securities engine not connected' }, 503)
- try {
- const holdings = await engine.getPortfolio()
- return c.json({ holdings })
- } catch (err) {
- return c.json({ error: String(err) }, 500)
- }
- })
-
- app.get('/orders', async (c) => {
- const engine = ctx.getSecuritiesEngine?.()
- if (!engine) return c.json({ error: 'Securities engine not connected' }, 503)
- try {
- const orders = await engine.getOrders()
- return c.json({ orders })
- } catch (err) {
- return c.json({ error: String(err) }, 500)
- }
- })
-
- // ==================== Market Data ====================
-
- app.get('/market-clock', async (c) => {
- const engine = ctx.getSecuritiesEngine?.()
- if (!engine) return c.json({ error: 'Securities engine not connected' }, 503)
- try {
- const clock = await engine.getMarketClock()
- return c.json(clock)
- } catch (err) {
- return c.json({ error: String(err) }, 500)
- }
- })
-
- app.get('/quote/:symbol', async (c) => {
- const engine = ctx.getSecuritiesEngine?.()
- if (!engine) return c.json({ error: 'Securities engine not connected' }, 503)
- try {
- const symbol = c.req.param('symbol')
- const quote = await engine.getQuote(symbol)
- return c.json(quote)
- } catch (err) {
- return c.json({ error: String(err) }, 500)
- }
- })
-
- // ==================== Wallet (trade decision history) ====================
-
- app.get('/wallet/log', (c) => {
- const wallet = ctx.getSecWallet?.()
- if (!wallet) return c.json({ error: 'Securities wallet not available' }, 503)
- const limit = Number(c.req.query('limit')) || 20
- const symbol = c.req.query('symbol') || undefined
- return c.json({ commits: wallet.log({ limit, symbol }) })
- })
-
- app.get('/wallet/show/:hash', (c) => {
- const wallet = ctx.getSecWallet?.()
- if (!wallet) return c.json({ error: 'Securities wallet not available' }, 503)
- const hash = c.req.param('hash')
- const commit = wallet.show(hash)
- if (!commit) return c.json({ error: 'Commit not found' }, 404)
- return c.json(commit)
- })
-
- app.get('/wallet/status', (c) => {
- const wallet = ctx.getSecWallet?.()
- if (!wallet) return c.json({ error: 'Securities wallet not available' }, 503)
- return c.json(wallet.status())
- })
-
- return app
-}
diff --git a/src/connectors/web/routes/trading-config.ts b/src/connectors/web/routes/trading-config.ts
new file mode 100644
index 00000000..a358827e
--- /dev/null
+++ b/src/connectors/web/routes/trading-config.ts
@@ -0,0 +1,157 @@
+import { Hono } from 'hono'
+import type { EngineContext } from '../../../core/types.js'
+import {
+ readPlatformsConfig, writePlatformsConfig,
+ readAccountsConfig, writeAccountsConfig,
+ platformConfigSchema, accountConfigSchema,
+} from '../../../core/config.js'
+
+/** Mask a secret string: show last 4 chars, prefix with "****" */
+function mask(value: string | undefined): string | undefined {
+ if (!value) return value
+ if (value.length <= 4) return '****'
+ return '****' + value.slice(-4)
+}
+
+/** Trading config CRUD routes: platforms + accounts */
+export function createTradingConfigRoutes(ctx: EngineContext) {
+ const app = new Hono()
+
+ // ==================== Read all ====================
+
+ app.get('/', async (c) => {
+ try {
+ const [platforms, accounts] = await Promise.all([
+ readPlatformsConfig(),
+ readAccountsConfig(),
+ ])
+ // Mask credentials in response
+ const maskedAccounts = accounts.map((a) => ({
+ ...a,
+ apiKey: mask(a.apiKey),
+ apiSecret: mask(a.apiSecret),
+ password: mask(a.password),
+ }))
+ return c.json({ platforms, accounts: maskedAccounts })
+ } catch (err) {
+ return c.json({ error: String(err) }, 500)
+ }
+ })
+
+ // ==================== Platforms CRUD ====================
+
+ app.put('/platforms/:id', async (c) => {
+ try {
+ const id = c.req.param('id')
+ const body = await c.req.json()
+ if (body.id !== id) {
+ return c.json({ error: 'Body id must match URL id' }, 400)
+ }
+ const validated = platformConfigSchema.parse(body)
+ const platforms = await readPlatformsConfig()
+ const idx = platforms.findIndex((p) => p.id === id)
+ if (idx >= 0) {
+ platforms[idx] = validated
+ } else {
+ platforms.push(validated)
+ }
+ await writePlatformsConfig(platforms)
+ return c.json(validated)
+ } catch (err) {
+ if (err instanceof Error && err.name === 'ZodError') {
+ return c.json({ error: 'Validation failed', details: JSON.parse(err.message) }, 400)
+ }
+ return c.json({ error: String(err) }, 500)
+ }
+ })
+
+ app.delete('/platforms/:id', async (c) => {
+ try {
+ const id = c.req.param('id')
+ const [platforms, accounts] = await Promise.all([
+ readPlatformsConfig(),
+ readAccountsConfig(),
+ ])
+ const refs = accounts.filter((a) => a.platformId === id)
+ if (refs.length > 0) {
+ return c.json({
+ error: `Platform "${id}" is referenced by ${refs.length} account(s): ${refs.map((a) => a.id).join(', ')}. Remove them first.`,
+ }, 400)
+ }
+ const filtered = platforms.filter((p) => p.id !== id)
+ if (filtered.length === platforms.length) {
+ return c.json({ error: `Platform "${id}" not found` }, 404)
+ }
+ await writePlatformsConfig(filtered)
+ return c.json({ success: true })
+ } catch (err) {
+ return c.json({ error: String(err) }, 500)
+ }
+ })
+
+ // ==================== Accounts CRUD ====================
+
+ app.put('/accounts/:id', async (c) => {
+ try {
+ const id = c.req.param('id')
+ const body = await c.req.json()
+ if (body.id !== id) {
+ return c.json({ error: 'Body id must match URL id' }, 400)
+ }
+
+ // Resolve masked credentials: if value is masked, keep the existing value
+ const accounts = await readAccountsConfig()
+ const existing = accounts.find((a) => a.id === id)
+ if (existing) {
+ if (body.apiKey && body.apiKey.startsWith('****')) body.apiKey = existing.apiKey
+ if (body.apiSecret && body.apiSecret.startsWith('****')) body.apiSecret = existing.apiSecret
+ if (body.password && body.password.startsWith('****')) body.password = existing.password
+ }
+
+ const validated = accountConfigSchema.parse(body)
+
+ // Validate platformId reference
+ const platforms = await readPlatformsConfig()
+ if (!platforms.some((p) => p.id === validated.platformId)) {
+ return c.json({ error: `Platform "${validated.platformId}" not found` }, 400)
+ }
+
+ const idx = accounts.findIndex((a) => a.id === id)
+ if (idx >= 0) {
+ accounts[idx] = validated
+ } else {
+ accounts.push(validated)
+ }
+ await writeAccountsConfig(accounts)
+ return c.json(validated)
+ } catch (err) {
+ if (err instanceof Error && err.name === 'ZodError') {
+ return c.json({ error: 'Validation failed', details: JSON.parse(err.message) }, 400)
+ }
+ return c.json({ error: String(err) }, 500)
+ }
+ })
+
+ app.delete('/accounts/:id', async (c) => {
+ try {
+ const id = c.req.param('id')
+ const accounts = await readAccountsConfig()
+ const filtered = accounts.filter((a) => a.id !== id)
+ if (filtered.length === accounts.length) {
+ return c.json({ error: `Account "${id}" not found` }, 404)
+ }
+ await writeAccountsConfig(filtered)
+ // Close running account instance if any
+ if (ctx.accountManager.has(id)) {
+ const account = ctx.accountManager.getAccount(id)
+ ctx.accountManager.removeAccount(id)
+ try { await account?.close() } catch { /* best effort */ }
+ }
+ return c.json({ success: true })
+ } catch (err) {
+ return c.json({ error: String(err) }, 500)
+ }
+ })
+
+ return app
+}
diff --git a/src/connectors/web/routes/trading.ts b/src/connectors/web/routes/trading.ts
new file mode 100644
index 00000000..b413d47f
--- /dev/null
+++ b/src/connectors/web/routes/trading.ts
@@ -0,0 +1,119 @@
+import { Hono } from 'hono'
+import type { EngineContext } from '../../../core/types.js'
+
+/** Unified trading routes — works with all account types via AccountManager */
+export function createTradingRoutes(ctx: EngineContext) {
+ const app = new Hono()
+
+ // ==================== Accounts listing ====================
+
+ app.get('/accounts', (c) => {
+ return c.json({ accounts: ctx.accountManager.listAccounts() })
+ })
+
+ // ==================== Aggregated equity ====================
+
+ app.get('/equity', async (c) => {
+ try {
+ const equity = await ctx.accountManager.getAggregatedEquity()
+ return c.json(equity)
+ } catch (err) {
+ return c.json({ error: String(err) }, 500)
+ }
+ })
+
+ // ==================== Per-account routes ====================
+
+ // Reconnect
+ app.post('/accounts/:id/reconnect', async (c) => {
+ const id = c.req.param('id')
+ const result = await ctx.reconnectAccount(id)
+ return c.json(result, result.success ? 200 : 500)
+ })
+
+ // Account info
+ app.get('/accounts/:id/account', async (c) => {
+ const account = ctx.accountManager.getAccount(c.req.param('id'))
+ if (!account) return c.json({ error: 'Account not found' }, 404)
+ try {
+ return c.json(await account.getAccount())
+ } catch (err) {
+ return c.json({ error: String(err) }, 500)
+ }
+ })
+
+ // Positions
+ app.get('/accounts/:id/positions', async (c) => {
+ const account = ctx.accountManager.getAccount(c.req.param('id'))
+ if (!account) return c.json({ error: 'Account not found' }, 404)
+ try {
+ const positions = await account.getPositions()
+ return c.json({ positions })
+ } catch (err) {
+ return c.json({ error: String(err) }, 500)
+ }
+ })
+
+ // Orders
+ app.get('/accounts/:id/orders', async (c) => {
+ const account = ctx.accountManager.getAccount(c.req.param('id'))
+ if (!account) return c.json({ error: 'Account not found' }, 404)
+ try {
+ const orders = await account.getOrders()
+ return c.json({ orders })
+ } catch (err) {
+ return c.json({ error: String(err) }, 500)
+ }
+ })
+
+ // Market clock (optional capability)
+ app.get('/accounts/:id/market-clock', async (c) => {
+ const account = ctx.accountManager.getAccount(c.req.param('id'))
+ if (!account) return c.json({ error: 'Account not found' }, 404)
+ if (!account.getMarketClock) return c.json({ error: 'Market clock not supported' }, 501)
+ try {
+ return c.json(await account.getMarketClock())
+ } catch (err) {
+ return c.json({ error: String(err) }, 500)
+ }
+ })
+
+ // Quote
+ app.get('/accounts/:id/quote/:symbol', async (c) => {
+ const account = ctx.accountManager.getAccount(c.req.param('id'))
+ if (!account) return c.json({ error: 'Account not found' }, 404)
+ try {
+ const symbol = c.req.param('symbol')
+ const quote = await account.getQuote({ symbol })
+ return c.json(quote)
+ } catch (err) {
+ return c.json({ error: String(err) }, 500)
+ }
+ })
+
+ // ==================== Per-account wallet/git routes ====================
+
+ app.get('/accounts/:id/wallet/log', (c) => {
+ const git = ctx.getAccountGit(c.req.param('id'))
+ if (!git) return c.json({ error: 'Account or wallet not found' }, 404)
+ const limit = Number(c.req.query('limit')) || 20
+ const symbol = c.req.query('symbol') || undefined
+ return c.json({ commits: git.log({ limit, symbol }) })
+ })
+
+ app.get('/accounts/:id/wallet/show/:hash', (c) => {
+ const git = ctx.getAccountGit(c.req.param('id'))
+ if (!git) return c.json({ error: 'Account or wallet not found' }, 404)
+ const commit = git.show(c.req.param('hash'))
+ if (!commit) return c.json({ error: 'Commit not found' }, 404)
+ return c.json(commit)
+ })
+
+ app.get('/accounts/:id/wallet/status', (c) => {
+ const git = ctx.getAccountGit(c.req.param('id'))
+ if (!git) return c.json({ error: 'Account or wallet not found' }, 404)
+ return c.json(git.status())
+ })
+
+ return app
+}
diff --git a/src/connectors/web/web-plugin.ts b/src/connectors/web/web-plugin.ts
index f6268182..0b8a6844 100644
--- a/src/connectors/web/web-plugin.ts
+++ b/src/connectors/web/web-plugin.ts
@@ -12,8 +12,8 @@ import { createConfigRoutes, createOpenbbRoutes } from './routes/config.js'
import { createEventsRoutes } from './routes/events.js'
import { createCronRoutes } from './routes/cron.js'
import { createHeartbeatRoutes } from './routes/heartbeat.js'
-import { createCryptoRoutes } from './routes/crypto.js'
-import { createSecuritiesRoutes } from './routes/securities.js'
+import { createTradingRoutes } from './routes/trading.js'
+import { createTradingConfigRoutes } from './routes/trading-config.js'
import { createDevRoutes } from './routes/dev.js'
import { createToolsRoutes } from './routes/tools.js'
@@ -50,18 +50,16 @@ export class WebPlugin implements Plugin {
app.route('/api/chat', createChatRoutes({ ctx, session, sseClients: this.sseClients }))
app.route('/api/media', createMediaRoutes())
app.route('/api/config', createConfigRoutes({
- onConnectorsChange: async () => { await ctx.reconnectConnectors?.() },
+ onConnectorsChange: async () => { await ctx.reconnectConnectors() },
}))
app.route('/api/openbb', createOpenbbRoutes())
app.route('/api/events', createEventsRoutes(ctx))
app.route('/api/cron', createCronRoutes(ctx))
app.route('/api/heartbeat', createHeartbeatRoutes(ctx))
- app.route('/api/crypto', createCryptoRoutes(ctx))
- app.route('/api/securities', createSecuritiesRoutes(ctx))
+ app.route('/api/trading/config', createTradingConfigRoutes(ctx))
+ app.route('/api/trading', createTradingRoutes(ctx))
app.route('/api/dev', createDevRoutes(ctx.connectorCenter))
- if (ctx.toolCenter) {
- app.route('/api/tools', createToolsRoutes(ctx.toolCenter))
- }
+ app.route('/api/tools', createToolsRoutes(ctx.toolCenter))
// ==================== Serve UI (Vite build output) ====================
const uiRoot = resolve('dist/ui')
@@ -74,7 +72,7 @@ export class WebPlugin implements Plugin {
)
// ==================== Start server ====================
- this.server = serve({ fetch: app.fetch, port: this.config.port }, (info) => {
+ this.server = serve({ fetch: app.fetch, port: this.config.port }, (info: { port: number }) => {
console.log(`web plugin listening on http://localhost:${info.port}`)
})
}
diff --git a/src/core/config.ts b/src/core/config.ts
index a59ae978..0ef8adc5 100644
--- a/src/core/config.ts
+++ b/src/core/config.ts
@@ -67,12 +67,7 @@ const cryptoSchema = z.object({
type: z.literal('none'),
}),
]).default({
- type: 'ccxt', exchange: 'bybit', sandbox: false, demoTrading: true, defaultMarketType: 'swap',
- // Only load linear (USDT-margined) markets from ccxt.
- // Default is ['spot', 'linear', 'inverse', 'option'] — the extra categories
- // add unnecessary parallel requests during loadMarkets(), and any single failure
- // (common on bybit demo API) aborts the entire init.
- options: { fetchMarkets: { types: ['linear'] } },
+ type: 'ccxt', exchange: 'binance', sandbox: false, demoTrading: true, defaultMarketType: 'swap',
}),
guards: z.array(z.object({
type: z.string(),
@@ -172,6 +167,53 @@ export const toolsSchema = z.object({
disabled: z.array(z.string()).default([]),
})
+// ==================== Platform + Account Config ====================
+
+const guardConfigSchema = z.object({
+ type: z.string(),
+ options: z.record(z.string(), z.unknown()).default({}),
+})
+
+const ccxtPlatformSchema = z.object({
+ id: z.string(),
+ label: z.string().optional(),
+ type: z.literal('ccxt'),
+ exchange: z.string(),
+ sandbox: z.boolean().default(false),
+ demoTrading: z.boolean().default(false),
+ defaultMarketType: z.enum(['spot', 'swap']).default('swap'),
+ options: z.record(z.string(), z.unknown()).optional(),
+})
+
+const alpacaPlatformSchema = z.object({
+ id: z.string(),
+ label: z.string().optional(),
+ type: z.literal('alpaca'),
+ paper: z.boolean().default(true),
+})
+
+export const platformConfigSchema = z.discriminatedUnion('type', [
+ ccxtPlatformSchema,
+ alpacaPlatformSchema,
+])
+
+export const platformsFileSchema = z.array(platformConfigSchema)
+
+export const accountConfigSchema = z.object({
+ id: z.string(),
+ platformId: z.string(),
+ label: z.string().optional(),
+ apiKey: z.string().optional(),
+ apiSecret: z.string().optional(),
+ password: z.string().optional(),
+ guards: z.array(guardConfigSchema).default([]),
+})
+
+export const accountsFileSchema = z.array(accountConfigSchema)
+
+export type PlatformConfig = z.infer
+export type AccountConfig = z.infer
+
// ==================== Unified Config Type ====================
export type Config = {
@@ -221,6 +263,7 @@ export async function loadConfig(): Promise {
const files = ['engine.json', 'agent.json', 'crypto.json', 'securities.json', 'openbb.json', 'compaction.json', 'ai-provider.json', 'heartbeat.json', 'connectors.json', 'news-collector.json', 'tools.json'] as const
const raws = await Promise.all(files.map((f) => loadJsonFile(f)))
+ // TODO: remove all migration blocks before v1.0 — no stable release yet, breaking changes are fine
// ---------- Migration: consolidate old ai-provider + model + api-keys → ai-provider ----------
const aiProviderRaw = raws[6] as Record | undefined
if (aiProviderRaw && !('backend' in aiProviderRaw)) {
@@ -277,6 +320,122 @@ export async function loadConfig(): Promise {
}
}
+// ==================== Trading Config Loader ====================
+
+/**
+ * Load platform + account config.
+ * Prefers platforms.json + accounts.json. Falls back to legacy crypto.json + securities.json.
+ */
+export async function loadTradingConfig(): Promise<{
+ platforms: PlatformConfig[]
+ accounts: AccountConfig[]
+}> {
+ const [rawPlatforms, rawAccounts] = await Promise.all([
+ loadJsonFile('platforms.json'),
+ loadJsonFile('accounts.json'),
+ ])
+
+ if (rawPlatforms !== undefined && rawAccounts !== undefined) {
+ return {
+ platforms: platformsFileSchema.parse(rawPlatforms),
+ accounts: accountsFileSchema.parse(rawAccounts),
+ }
+ }
+
+ // Migration: derive from legacy crypto.json + securities.json
+ return migrateLegacyTradingConfig()
+}
+
+/** Derive platform+account config from old crypto.json + securities.json, then write to disk.
+ * TODO: remove before v1.0 — drop crypto.json/securities.json support entirely */
+async function migrateLegacyTradingConfig(): Promise<{
+ platforms: PlatformConfig[]
+ accounts: AccountConfig[]
+}> {
+ const [rawCrypto, rawSecurities] = await Promise.all([
+ loadJsonFile('crypto.json'),
+ loadJsonFile('securities.json'),
+ ])
+
+ const crypto = cryptoSchema.parse(rawCrypto ?? {})
+ const securities = securitiesSchema.parse(rawSecurities ?? {})
+
+ const platforms: PlatformConfig[] = []
+ const accounts: AccountConfig[] = []
+
+ if (crypto.provider.type === 'ccxt') {
+ const p = crypto.provider
+ const platformId = `${p.exchange}-platform`
+ platforms.push({
+ id: platformId,
+ type: 'ccxt',
+ exchange: p.exchange,
+ sandbox: p.sandbox,
+ demoTrading: p.demoTrading,
+ defaultMarketType: p.defaultMarketType,
+ options: p.options,
+ })
+ accounts.push({
+ id: `${p.exchange}-main`,
+ platformId,
+ apiKey: p.apiKey,
+ apiSecret: p.apiSecret,
+ password: p.password,
+ guards: crypto.guards,
+ })
+ }
+
+ if (securities.provider.type === 'alpaca') {
+ const p = securities.provider
+ const platformId = 'alpaca-platform'
+ platforms.push({
+ id: platformId,
+ type: 'alpaca',
+ paper: p.paper,
+ })
+ accounts.push({
+ id: p.paper ? 'alpaca-paper' : 'alpaca-live',
+ platformId,
+ apiKey: p.apiKey,
+ apiSecret: p.secretKey,
+ guards: securities.guards,
+ })
+ }
+
+ // Seed to disk so the user can edit the new format directly
+ await mkdir(CONFIG_DIR, { recursive: true })
+ await Promise.all([
+ writeFile(resolve(CONFIG_DIR, 'platforms.json'), JSON.stringify(platforms, null, 2) + '\n'),
+ writeFile(resolve(CONFIG_DIR, 'accounts.json'), JSON.stringify(accounts, null, 2) + '\n'),
+ ])
+
+ return { platforms, accounts }
+}
+
+// ==================== Platform / Account file helpers ====================
+
+export async function readPlatformsConfig(): Promise {
+ const raw = await loadJsonFile('platforms.json')
+ return platformsFileSchema.parse(raw ?? [])
+}
+
+export async function writePlatformsConfig(platforms: PlatformConfig[]): Promise {
+ const validated = platformsFileSchema.parse(platforms)
+ await mkdir(CONFIG_DIR, { recursive: true })
+ await writeFile(resolve(CONFIG_DIR, 'platforms.json'), JSON.stringify(validated, null, 2) + '\n')
+}
+
+export async function readAccountsConfig(): Promise {
+ const raw = await loadJsonFile('accounts.json')
+ return accountsFileSchema.parse(raw ?? [])
+}
+
+export async function writeAccountsConfig(accounts: AccountConfig[]): Promise {
+ const validated = accountsFileSchema.parse(accounts)
+ await mkdir(CONFIG_DIR, { recursive: true })
+ await writeFile(resolve(CONFIG_DIR, 'accounts.json'), JSON.stringify(validated, null, 2) + '\n')
+}
+
// ==================== Hot-read helpers ====================
/** Read agent config from disk (called per-request for hot-reload). */
diff --git a/src/core/types.ts b/src/core/types.ts
index 9db08c35..84b83569 100644
--- a/src/core/types.ts
+++ b/src/core/types.ts
@@ -1,5 +1,5 @@
-import type { ICryptoTradingEngine, Wallet } from '../extension/crypto-trading/index.js'
-import type { ISecuritiesTradingEngine, SecWallet } from '../extension/securities-trading/index.js'
+import type { AccountManager } from '../extension/trading/index.js'
+import type { ITradingGit } from '../extension/trading/git/interfaces.js'
import type { CronEngine } from '../task/cron/engine.js'
import type { Heartbeat } from '../task/heartbeat/index.js'
import type { Config } from './config.js'
@@ -25,24 +25,20 @@ export interface ReconnectResult {
export interface EngineContext {
config: Config
connectorCenter: ConnectorCenter
- cryptoEngine: ICryptoTradingEngine | null
engine: Engine
eventLog: EventLog
heartbeat: Heartbeat
cronEngine: CronEngine
- reconnectCrypto?: () => Promise
- reconnectSecurities?: () => Promise
- reconnectConnectors?: () => Promise
- /** Current crypto trading engine (updates on reconnect). */
- getCryptoEngine?: () => ICryptoTradingEngine | null
- /** Current securities trading engine (updates on reconnect). */
- getSecuritiesEngine?: () => ISecuritiesTradingEngine | null
- /** Current crypto wallet (updates on reconnect). */
- getCryptoWallet?: () => Wallet | null
- /** Current securities wallet (updates on reconnect). */
- getSecWallet?: () => SecWallet | null
- /** Central tool registry. */
- toolCenter?: ToolCenter
+ toolCenter: ToolCenter
+
+ // Trading (unified account model)
+ accountManager: AccountManager
+ /** Get the TradingGit instance for an account by ID. */
+ getAccountGit: (accountId: string) => ITradingGit | undefined
+ /** Reconnect a specific trading account by ID. */
+ reconnectAccount: (accountId: string) => Promise
+ /** Reconnect connector plugins (Telegram, MCP-Ask, etc.). */
+ reconnectConnectors: () => Promise
}
/** A media attachment collected from tool results (e.g. browser screenshots). */
diff --git a/src/extension/crypto-trading/adapter.ts b/src/extension/crypto-trading/adapter.ts
deleted file mode 100644
index 5d845e8b..00000000
--- a/src/extension/crypto-trading/adapter.ts
+++ /dev/null
@@ -1,368 +0,0 @@
-import { tool } from 'ai';
-import { z } from 'zod';
-import type { ICryptoTradingEngine } from './interfaces';
-import type { IWallet } from './wallet/interfaces';
-import type { OrderStatusUpdate, WalletState } from './wallet/types';
-import { createCryptoWalletToolsImpl } from './wallet/adapter';
-
-/**
- * Create crypto trading AI tools (market interaction + wallet management)
- *
- * Wallet operations (git-like decision tracking):
- * - cryptoWalletCommit, cryptoWalletPush, cryptoWalletLog, cryptoWalletShow, cryptoWalletStatus, cryptoWalletSync, cryptoSimulatePriceChange
- *
- * Trading operations (staged via wallet):
- * - cryptoPlaceOrder, cryptoClosePosition, cryptoCancelOrder, cryptoAdjustLeverage
- *
- * Query operations (direct):
- * - cryptoGetPositions, cryptoGetOrders, cryptoGetAccount
- */
-export function createCryptoTradingTools(
- tradingEngine: ICryptoTradingEngine,
- wallet: IWallet,
- getWalletState?: () => Promise,
-) {
- return {
- // ==================== Wallet operations ====================
- ...createCryptoWalletToolsImpl(wallet),
-
- // ==================== Sync ====================
-
- cryptoWalletSync: tool({
- description: `
-Sync pending order statuses from exchange (like "git pull").
-
-Checks all pending orders from previous commits and fetches their latest
-status from the exchange. Creates a sync commit recording any changes.
-
-Use this after placing limit orders to check if they've been filled.
-Returns the number of orders that changed status.
- `.trim(),
- inputSchema: z.object({}),
- execute: async () => {
- if (!getWalletState) {
- return { message: 'Trading engine not connected. Cannot sync.', updatedCount: 0 };
- }
-
- const pendingOrders = wallet.getPendingOrderIds();
- if (pendingOrders.length === 0) {
- return { message: 'No pending orders to sync.', updatedCount: 0 };
- }
-
- const exchangeOrders = await tradingEngine.getOrders();
- const updates: OrderStatusUpdate[] = [];
-
- for (const { orderId, symbol } of pendingOrders) {
- const exchangeOrder = exchangeOrders.find(o => o.id === orderId);
- if (!exchangeOrder) continue;
-
- const newStatus = exchangeOrder.status;
- if (newStatus !== 'pending') {
- updates.push({
- orderId,
- symbol,
- previousStatus: 'pending',
- currentStatus: newStatus,
- filledPrice: exchangeOrder.filledPrice,
- filledSize: exchangeOrder.filledSize,
- });
- }
- }
-
- if (updates.length === 0) {
- return {
- message: `All ${pendingOrders.length} order(s) still pending.`,
- updatedCount: 0,
- };
- }
-
- const state = await getWalletState();
- return await wallet.sync(updates, state);
- },
- }),
-
- // ==================== Trading operations (staged to Wallet) ====================
-
- cryptoPlaceOrder: tool({
- description: `
-Stage a crypto trading order in wallet (will execute on cryptoWalletPush).
-
-BEFORE placing orders, you SHOULD:
-1. Check cryptoWalletLog({ symbol }) to review your history for THIS symbol
-2. Check cryptoGetPositions to see current holdings
-3. Verify this trade aligns with your stated strategy
-
-Supports two modes:
-- size-based: Specify coin amount (e.g. 0.5 BTC)
-- usd_size-based: Specify USD value (e.g. 1000 USDT)
-
-For CLOSING positions, use cryptoClosePosition tool instead.
-
-NOTE: This stages the operation. Call cryptoWalletCommit + cryptoWalletPush to execute.
- `.trim(),
- inputSchema: z.object({
- symbol: z.string().describe('Trading pair symbol, e.g. BTC/USD'),
- side: z
- .enum(['buy', 'sell'])
- .describe('Buy = open long, Sell = open short'),
- type: z
- .enum(['market', 'limit'])
- .describe(
- 'Market order (immediate) or Limit order (at specific price)',
- ),
- size: z
- .number()
- .positive()
- .optional()
- .describe(
- 'Order size in coins (e.g. 0.5 BTC). Mutually exclusive with usd_size.',
- ),
- usd_size: z
- .number()
- .positive()
- .optional()
- .describe(
- 'Order size in USD (e.g. 1000 USDT). Will auto-calculate coin size. Mutually exclusive with size.',
- ),
- price: z
- .number()
- .positive()
- .optional()
- .describe('Price (required for limit orders)'),
- leverage: z
- .number()
- .int()
- .min(1)
- .max(20)
- .optional()
- .describe('Leverage (1-20, default 1)'),
- reduceOnly: z
- .boolean()
- .optional()
- .describe('Only reduce position (close only)'),
- }),
- execute: ({
- symbol,
- side,
- type,
- size,
- usd_size,
- price,
- leverage,
- reduceOnly,
- }) => {
- return wallet.add({
- action: 'placeOrder',
- params: { symbol, side, type, size, usd_size, price, leverage, reduceOnly },
- });
- },
- }),
-
- cryptoClosePosition: tool({
- description: `
-Stage a crypto position close in wallet (will execute on cryptoWalletPush).
-
-This is the preferred way to close positions instead of using cryptoPlaceOrder with reduceOnly.
-
-NOTE: This stages the operation. Call cryptoWalletCommit + cryptoWalletPush to execute.
- `.trim(),
- inputSchema: z.object({
- symbol: z.string().describe('Trading pair symbol, e.g. BTC/USD'),
- size: z
- .number()
- .positive()
- .optional()
- .describe('Size to close (default: close entire position)'),
- }),
- execute: ({ symbol, size }) => {
- return wallet.add({
- action: 'closePosition',
- params: { symbol, size },
- });
- },
- }),
-
- cryptoCancelOrder: tool({
- description: `
-Stage an order cancellation in wallet (will execute on cryptoWalletPush).
-
-NOTE: This stages the operation. Call cryptoWalletCommit + cryptoWalletPush to execute.
- `.trim(),
- inputSchema: z.object({
- orderId: z.string().describe('Order ID to cancel'),
- }),
- execute: ({ orderId }) => {
- return wallet.add({
- action: 'cancelOrder',
- params: { orderId },
- });
- },
- }),
-
- cryptoAdjustLeverage: tool({
- description: `
-Stage a leverage adjustment in wallet (will execute on cryptoWalletPush).
-
-Adjust leverage for an existing position without changing position size.
-This will adjust margin requirements.
-
-NOTE: This stages the operation. Call cryptoWalletCommit + cryptoWalletPush to execute.
- `.trim(),
- inputSchema: z.object({
- symbol: z.string().describe('Trading pair symbol, e.g. BTC/USD'),
- newLeverage: z
- .number()
- .int()
- .min(1)
- .max(20)
- .describe('New leverage (1-20)'),
- }),
- execute: ({ symbol, newLeverage }) => {
- return wallet.add({
- action: 'adjustLeverage',
- params: { symbol, newLeverage },
- });
- },
- }),
-
- // ==================== Query operations (no staging needed) ====================
-
- cryptoGetPositions: tool({
- description: `Query current open crypto positions. Can filter by symbol or get all positions.
-
-Each position includes:
-- All standard position fields (symbol, side, size, entryPrice, leverage, margin, markPrice, unrealizedPnL, positionValue, etc.)
-- percentageOfEquity: This position's value as percentage of TOTAL CAPITAL (use this for risk control, e.g. "max 10% per trade")
-- percentageOfTotal: This position's value as percentage of total positions (use this for diversification check)
-- pnlRatioToMargin: Unrealized PnL as a percentage of margin
-
-IMPORTANT: If result is an empty array [], it means you currently have NO open positions.
-RISK CHECK: Before placing new orders, verify that percentageOfEquity doesn't exceed your per-trade limit.`,
- inputSchema: z.object({
- symbol: z
- .string()
- .optional()
- .describe(
- 'Trading pair symbol to filter (e.g. "BTC/USD"), or "all" for all positions (default: all)',
- ),
- }),
- execute: async ({ symbol }) => {
- const allPositions = await tradingEngine.getPositions();
- const account = await tradingEngine.getAccount();
-
- const totalPositionValue = allPositions.reduce(
- (sum, p) => sum + p.positionValue,
- 0,
- );
-
- const positionsWithPercentage = allPositions.map((position) => {
- const pnlRatio =
- position.margin > 0
- ? (position.unrealizedPnL / position.margin) * 100
- : 0;
- const percentOfEquity =
- account.equity > 0
- ? (position.positionValue / account.equity) * 100
- : 0;
- const percentOfPositions =
- totalPositionValue > 0
- ? (position.positionValue / totalPositionValue) * 100
- : 0;
-
- return {
- ...position,
- percentageOfEquity: `${percentOfEquity.toFixed(1)}%`,
- percentageOfTotal: `${percentOfPositions.toFixed(1)}%`,
- pnlRatioToMargin: `${pnlRatio >= 0 ? '+' : ''}${pnlRatio.toFixed(1)}%`,
- };
- });
-
- const filtered = (!symbol || symbol === 'all')
- ? positionsWithPercentage
- : positionsWithPercentage.filter((p) => p.symbol === symbol);
-
- if (filtered.length === 0) {
- return {
- positions: [],
- message:
- 'No open positions. You currently have no active crypto trades.',
- };
- }
-
- return filtered;
- },
- }),
-
- cryptoGetOrders: tool({
- description: 'Query crypto order history (filled, pending, cancelled)',
- inputSchema: z.object({}),
- execute: async () => {
- return await tradingEngine.getOrders();
- },
- }),
-
- cryptoGetAccount: tool({
- description:
- 'Query crypto account info (balance, margin, unrealizedPnL, equity, realizedPnL, totalPnL). totalPnL = realizedPnL + unrealizedPnL.',
- inputSchema: z.object({}),
- execute: async () => {
- return await tradingEngine.getAccount();
- },
- }),
-
- cryptoGetTicker: tool({
- description: `Query the current exchange ticker for a symbol.
-
-Returns real-time price data directly from the exchange:
-- last: last traded price
-- bid/ask: current best bid and ask
-- high/low: 24h high and low
-- volume: 24h base volume
-
-This reflects the exchange's own price, not an external data provider.`,
- inputSchema: z.object({
- symbol: z.string().describe('Trading pair symbol, e.g. BTC/USD'),
- }),
- execute: async ({ symbol }) => {
- return await tradingEngine.getTicker(symbol);
- },
- }),
-
- cryptoGetOrderBook: tool({
- description: `Query the order book (market depth) for a symbol.
-
-Returns bids (buy orders) and asks (sell orders) sorted by price:
-- bids: descending (best/highest bid first)
-- asks: ascending (best/lowest ask first)
-Each level is [price, amount].
-
-Use this to evaluate liquidity and potential slippage before placing large orders.`,
- inputSchema: z.object({
- symbol: z.string().describe('Trading pair symbol, e.g. BTC/USD'),
- limit: z.number().int().min(1).max(100).optional()
- .describe('Number of price levels per side (default: 20)'),
- }),
- execute: async ({ symbol, limit }) => {
- return await tradingEngine.getOrderBook(symbol, limit ?? 20);
- },
- }),
-
- cryptoGetFundingRate: tool({
- description: `Query the current funding rate for a perpetual contract.
-
-Returns:
-- fundingRate: current/latest funding rate (e.g. 0.0001 = 0.01%)
-- nextFundingTime: when the next funding payment occurs
-- previousFundingRate: the previous period's rate
-
-Positive rate = longs pay shorts. Negative rate = shorts pay longs.
-Essential for evaluating carry cost on perpetual positions.`,
- inputSchema: z.object({
- symbol: z.string().describe('Trading pair symbol, e.g. BTC/USD'),
- }),
- execute: async ({ symbol }) => {
- return await tradingEngine.getFundingRate(symbol);
- },
- }),
- };
-}
diff --git a/src/extension/crypto-trading/factory.ts b/src/extension/crypto-trading/factory.ts
deleted file mode 100644
index d02ba7ff..00000000
--- a/src/extension/crypto-trading/factory.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-/**
- * Crypto Trading Engine Factory
- *
- * Instantiate the corresponding crypto trading engine provider based on config
- */
-
-import type { ICryptoTradingEngine } from './interfaces';
-import type { Config } from '../../core/config';
-import { CcxtTradingEngine } from './providers/ccxt/index';
-
-export interface CryptoTradingEngineResult {
- engine: ICryptoTradingEngine;
- close: () => Promise;
-}
-
-const MAX_RETRIES = 5;
-const BACKOFF_BASE_MS = 2_000; // 2s, 4s, 8s, 16s, 32s
-
-/**
- * Create a crypto trading engine
- *
- * @returns engine instance, or null (provider = 'none')
- */
-export async function createCryptoTradingEngine(
- config: Config,
-): Promise {
- const providerConfig = config.crypto.provider;
-
- switch (providerConfig.type) {
- case 'none':
- return null;
-
- case 'ccxt': {
- const apiKey = providerConfig.apiKey;
- const apiSecret = providerConfig.apiSecret;
- const password = providerConfig.password;
-
- if (!apiKey || !apiSecret) {
- throw new Error(
- 'apiKey and apiSecret must be configured for CCXT provider (Settings → Crypto Trading)',
- );
- }
-
- const engine = new CcxtTradingEngine({
- exchange: providerConfig.exchange,
- apiKey,
- apiSecret,
- password,
- sandbox: providerConfig.sandbox,
- demoTrading: providerConfig.demoTrading,
- defaultMarketType: providerConfig.defaultMarketType,
- options: providerConfig.options,
- });
-
- await initWithRetry(engine, providerConfig.exchange);
-
- return {
- engine,
- close: () => engine.close(),
- };
- }
-
- default:
- throw new Error(`Unknown crypto trading provider: ${(providerConfig as { type: string }).type}`);
- }
-}
-
-async function initWithRetry(engine: CcxtTradingEngine, exchangeName: string): Promise {
- for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
- try {
- await engine.init();
- if (attempt > 1) {
- console.log(`ccxt(${exchangeName}): connected after ${attempt} attempts`);
- }
- return;
- } catch (err) {
- const isLast = attempt === MAX_RETRIES;
- const delayMs = BACKOFF_BASE_MS * 2 ** (attempt - 1);
-
- if (isLast) {
- console.error(`ccxt(${exchangeName}): init failed after ${MAX_RETRIES} attempts, giving up`);
- throw err;
- }
-
- console.warn(
- `ccxt(${exchangeName}): init attempt ${attempt}/${MAX_RETRIES} failed — ${err instanceof Error ? err.message : err}. Retrying in ${delayMs / 1000}s...`,
- );
- await new Promise((r) => setTimeout(r, delayMs));
- }
- }
-}
diff --git a/src/extension/crypto-trading/guards/cooldown.spec.ts b/src/extension/crypto-trading/guards/cooldown.spec.ts
deleted file mode 100644
index 8ab038a8..00000000
--- a/src/extension/crypto-trading/guards/cooldown.spec.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-import { describe, it, expect, vi, afterEach } from 'vitest';
-import { CooldownGuard } from './cooldown.js';
-import type { GuardContext } from './types.js';
-
-function makeCtx(symbol = 'BTC/USD'): GuardContext {
- return {
- operation: { action: 'placeOrder', params: { symbol, side: 'buy', type: 'market', usd_size: 1000 } },
- positions: [],
- account: { balance: 10000, totalMargin: 0, unrealizedPnL: 0, equity: 10000, realizedPnL: 0, totalPnL: 0 },
- };
-}
-
-describe('CooldownGuard', () => {
- afterEach(() => {
- vi.restoreAllMocks();
- });
-
- it('allows first trade for a symbol', () => {
- const guard = new CooldownGuard({ minIntervalMs: 60000 });
-
- expect(guard.check(makeCtx())).toBeNull();
- });
-
- it('rejects second trade within cooldown period', () => {
- const guard = new CooldownGuard({ minIntervalMs: 60000 });
-
- guard.check(makeCtx()); // first trade — allowed, records timestamp
- const result = guard.check(makeCtx()); // second trade — within cooldown
-
- expect(result).toContain('Cooldown active');
- expect(result).toContain('BTC/USD');
- });
-
- it('allows trade after cooldown expires', () => {
- const guard = new CooldownGuard({ minIntervalMs: 1000 });
- const now = Date.now();
-
- vi.spyOn(Date, 'now').mockReturnValue(now);
- guard.check(makeCtx()); // first trade
-
- vi.spyOn(Date, 'now').mockReturnValue(now + 1001); // cooldown expired
- expect(guard.check(makeCtx())).toBeNull();
- });
-
- it('tracks symbols independently', () => {
- const guard = new CooldownGuard({ minIntervalMs: 60000 });
-
- guard.check(makeCtx('BTC/USD')); // BTC trade
- expect(guard.check(makeCtx('ETH/USD'))).toBeNull(); // ETH still OK
- });
-
- it('ignores non-placeOrder actions', () => {
- const guard = new CooldownGuard({ minIntervalMs: 60000 });
- const ctx: GuardContext = {
- operation: { action: 'cancelOrder', params: { orderId: 'abc' } },
- positions: [],
- account: { balance: 10000, totalMargin: 0, unrealizedPnL: 0, equity: 10000, realizedPnL: 0, totalPnL: 0 },
- };
-
- expect(guard.check(ctx)).toBeNull();
- });
-
- it('uses default 60s when no config provided', () => {
- const guard = new CooldownGuard({});
- const now = Date.now();
-
- vi.spyOn(Date, 'now').mockReturnValue(now);
- guard.check(makeCtx());
-
- vi.spyOn(Date, 'now').mockReturnValue(now + 59000); // 59s < 60s
- expect(guard.check(makeCtx())).not.toBeNull();
-
- vi.spyOn(Date, 'now').mockReturnValue(now + 60001); // 60s+ — OK
- expect(guard.check(makeCtx())).toBeNull();
- });
-});
diff --git a/src/extension/crypto-trading/guards/cooldown.ts b/src/extension/crypto-trading/guards/cooldown.ts
deleted file mode 100644
index 897d734d..00000000
--- a/src/extension/crypto-trading/guards/cooldown.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import type { OperationGuard, GuardContext } from './types.js';
-
-const DEFAULT_MIN_INTERVAL_MS = 60_000;
-
-export class CooldownGuard implements OperationGuard {
- readonly name = 'cooldown';
- private minIntervalMs: number;
- private lastTradeTime = new Map();
-
- constructor(options: Record) {
- this.minIntervalMs = Number(options.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS);
- }
-
- check(ctx: GuardContext): string | null {
- if (ctx.operation.action !== 'placeOrder') return null;
-
- const symbol = ctx.operation.params.symbol as string;
- const now = Date.now();
- const lastTime = this.lastTradeTime.get(symbol);
-
- if (lastTime != null) {
- const elapsed = now - lastTime;
- if (elapsed < this.minIntervalMs) {
- const remaining = Math.ceil((this.minIntervalMs - elapsed) / 1000);
- return `Cooldown active for ${symbol}: ${remaining}s remaining`;
- }
- }
-
- this.lastTradeTime.set(symbol, now);
- return null;
- }
-}
diff --git a/src/extension/crypto-trading/guards/guard-pipeline.spec.ts b/src/extension/crypto-trading/guards/guard-pipeline.spec.ts
deleted file mode 100644
index c4cb5859..00000000
--- a/src/extension/crypto-trading/guards/guard-pipeline.spec.ts
+++ /dev/null
@@ -1,108 +0,0 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { createGuardPipeline } from './guard-pipeline.js';
-import type { ICryptoTradingEngine } from '../interfaces.js';
-import type { OperationGuard } from './types.js';
-import type { Operation } from '../wallet/types.js';
-
-function createMockEngine(): ICryptoTradingEngine {
- return {
- placeOrder: vi.fn(),
- getPositions: vi.fn().mockResolvedValue([]),
- getOrders: vi.fn(),
- getAccount: vi.fn().mockResolvedValue({
- balance: 10000, totalMargin: 0, unrealizedPnL: 0,
- equity: 10000, realizedPnL: 0, totalPnL: 0,
- }),
- cancelOrder: vi.fn(),
- adjustLeverage: vi.fn(),
- getTicker: vi.fn(),
- getFundingRate: vi.fn(),
- getOrderBook: vi.fn(),
- };
-}
-
-const placeOrderOp: Operation = {
- action: 'placeOrder',
- params: { symbol: 'BTC/USD', side: 'buy', type: 'market', usd_size: 1000 },
-};
-
-describe('createGuardPipeline', () => {
- let engine: ICryptoTradingEngine;
- let dispatcher: ReturnType;
-
- beforeEach(() => {
- engine = createMockEngine();
- dispatcher = vi.fn().mockResolvedValue({ success: true, orderId: 'ord-001' });
- });
-
- it('returns dispatcher directly when guards array is empty', () => {
- const pipeline = createGuardPipeline(dispatcher, engine, []);
- expect(pipeline).toBe(dispatcher);
- });
-
- it('passes operation through when all guards return null', async () => {
- const guard: OperationGuard = { name: 'allow-all', check: () => null };
- const pipeline = createGuardPipeline(dispatcher, engine, [guard]);
-
- const result = await pipeline(placeOrderOp);
-
- expect(dispatcher).toHaveBeenCalledWith(placeOrderOp);
- expect(result).toEqual({ success: true, orderId: 'ord-001' });
- });
-
- it('rejects with guard error when a guard returns a string', async () => {
- const guard: OperationGuard = { name: 'blocker', check: () => 'too risky' };
- const pipeline = createGuardPipeline(dispatcher, engine, [guard]);
-
- const result = await pipeline(placeOrderOp);
-
- expect(dispatcher).not.toHaveBeenCalled();
- expect(result).toEqual({ success: false, error: '[guard:blocker] too risky' });
- });
-
- it('short-circuits on first rejection', async () => {
- const guard1: OperationGuard = { name: 'first', check: () => 'nope' };
- const guard2Check = vi.fn().mockReturnValue(null);
- const guard2: OperationGuard = { name: 'second', check: guard2Check };
- const pipeline = createGuardPipeline(dispatcher, engine, [guard1, guard2]);
-
- await pipeline(placeOrderOp);
-
- expect(guard2Check).not.toHaveBeenCalled();
- expect(dispatcher).not.toHaveBeenCalled();
- });
-
- it('builds context with positions and account from engine', async () => {
- const positions = [{ symbol: 'BTC/USD', side: 'long' as const, size: 0.5, entryPrice: 90000, leverage: 5, margin: 9000, liquidationPrice: 72000, markPrice: 95000, unrealizedPnL: 2500, positionValue: 47500 }];
- const account = { balance: 10000, totalMargin: 0, unrealizedPnL: 0, equity: 10000, realizedPnL: 0, totalPnL: 0 };
- (engine.getPositions as ReturnType).mockResolvedValue(positions);
- (engine.getAccount as ReturnType).mockResolvedValue(account);
-
- let capturedCtx: unknown;
- const guard: OperationGuard = {
- name: 'spy',
- check: (ctx) => { capturedCtx = ctx; return null; },
- };
- const pipeline = createGuardPipeline(dispatcher, engine, [guard]);
-
- await pipeline(placeOrderOp);
-
- expect(capturedCtx).toEqual({
- operation: placeOrderOp,
- positions,
- account,
- });
- });
-
- it('supports async guards', async () => {
- const guard: OperationGuard = {
- name: 'async-blocker',
- check: async () => 'async rejection',
- };
- const pipeline = createGuardPipeline(dispatcher, engine, [guard]);
-
- const result = await pipeline(placeOrderOp);
-
- expect(result).toEqual({ success: false, error: '[guard:async-blocker] async rejection' });
- });
-});
diff --git a/src/extension/crypto-trading/guards/guard-pipeline.ts b/src/extension/crypto-trading/guards/guard-pipeline.ts
deleted file mode 100644
index a80bec7a..00000000
--- a/src/extension/crypto-trading/guards/guard-pipeline.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-/**
- * Guard Pipeline
- *
- * The only place that touches the engine: assembles a GuardContext,
- * then passes it through the guard chain. Guards themselves never
- * see the engine.
- */
-
-import type { Operation } from '../wallet/types.js';
-import type { ICryptoTradingEngine } from '../interfaces.js';
-import type { OperationGuard, GuardContext } from './types.js';
-
-export function createGuardPipeline(
- dispatcher: (op: Operation) => Promise,
- engine: ICryptoTradingEngine,
- guards: OperationGuard[],
-): (op: Operation) => Promise {
- if (guards.length === 0) return dispatcher;
-
- return async (op: Operation): Promise => {
- const [positions, account] = await Promise.all([
- engine.getPositions(),
- engine.getAccount(),
- ]);
-
- const ctx: GuardContext = { operation: op, positions, account };
-
- for (const guard of guards) {
- const rejection = await guard.check(ctx);
- if (rejection != null) {
- return { success: false, error: `[guard:${guard.name}] ${rejection}` };
- }
- }
-
- return dispatcher(op);
- };
-}
diff --git a/src/extension/crypto-trading/guards/index.ts b/src/extension/crypto-trading/guards/index.ts
deleted file mode 100644
index c87d6053..00000000
--- a/src/extension/crypto-trading/guards/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export type { OperationGuard, GuardContext, GuardRegistryEntry } from './types.js';
-export { createGuardPipeline } from './guard-pipeline.js';
-export { resolveGuards, registerGuard } from './registry.js';
diff --git a/src/extension/crypto-trading/guards/max-leverage.spec.ts b/src/extension/crypto-trading/guards/max-leverage.spec.ts
deleted file mode 100644
index bbd07a66..00000000
--- a/src/extension/crypto-trading/guards/max-leverage.spec.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { MaxLeverageGuard } from './max-leverage.js';
-import type { GuardContext } from './types.js';
-
-function makeCtx(action: string, params: Record): GuardContext {
- return {
- operation: { action: action as 'placeOrder', params },
- positions: [],
- account: { balance: 10000, totalMargin: 0, unrealizedPnL: 0, equity: 10000, realizedPnL: 0, totalPnL: 0 },
- };
-}
-
-describe('MaxLeverageGuard', () => {
- it('allows placeOrder within global limit', () => {
- const guard = new MaxLeverageGuard({ maxLeverage: 10 });
- const ctx = makeCtx('placeOrder', { symbol: 'BTC/USD', side: 'buy', type: 'market', leverage: 10 });
-
- expect(guard.check(ctx)).toBeNull();
- });
-
- it('rejects placeOrder exceeding global limit', () => {
- const guard = new MaxLeverageGuard({ maxLeverage: 10 });
- const ctx = makeCtx('placeOrder', { symbol: 'BTC/USD', side: 'buy', type: 'market', leverage: 15 });
-
- expect(guard.check(ctx)).toContain('15x');
- expect(guard.check(ctx)).toContain('10x');
- });
-
- it('applies symbol override over global limit', () => {
- const guard = new MaxLeverageGuard({
- maxLeverage: 10,
- symbolOverrides: { 'DOGE/USD': 3 },
- });
-
- const dogeCtx = makeCtx('placeOrder', { symbol: 'DOGE/USD', side: 'buy', type: 'market', leverage: 5 });
- expect(guard.check(dogeCtx)).toContain('3x');
-
- const btcCtx = makeCtx('placeOrder', { symbol: 'BTC/USD', side: 'buy', type: 'market', leverage: 5 });
- expect(btcCtx.operation.params.leverage).toBe(5);
- expect(guard.check(btcCtx)).toBeNull();
- });
-
- it('intercepts adjustLeverage action', () => {
- const guard = new MaxLeverageGuard({ maxLeverage: 10 });
- const ctx = makeCtx('adjustLeverage', { symbol: 'BTC/USD', newLeverage: 20 });
-
- expect(guard.check(ctx)).toContain('20x');
- });
-
- it('ignores placeOrder without leverage param', () => {
- const guard = new MaxLeverageGuard({ maxLeverage: 5 });
- const ctx = makeCtx('placeOrder', { symbol: 'BTC/USD', side: 'buy', type: 'market' });
-
- expect(guard.check(ctx)).toBeNull();
- });
-
- it('ignores non-leverage actions', () => {
- const guard = new MaxLeverageGuard({ maxLeverage: 1 });
- const ctx = makeCtx('cancelOrder', { orderId: 'abc' });
-
- expect(guard.check(ctx)).toBeNull();
- });
-
- it('uses default 10x when no config provided', () => {
- const guard = new MaxLeverageGuard({});
- const ctx = makeCtx('placeOrder', { symbol: 'BTC/USD', side: 'buy', type: 'market', leverage: 11 });
-
- expect(guard.check(ctx)).not.toBeNull();
- });
-});
diff --git a/src/extension/crypto-trading/guards/max-leverage.ts b/src/extension/crypto-trading/guards/max-leverage.ts
deleted file mode 100644
index f021e48a..00000000
--- a/src/extension/crypto-trading/guards/max-leverage.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import type { OperationGuard, GuardContext } from './types.js';
-
-const DEFAULT_MAX_LEVERAGE = 10;
-
-export class MaxLeverageGuard implements OperationGuard {
- readonly name = 'max-leverage';
- private maxLeverage: number;
- private symbolOverrides: Record;
-
- constructor(options: Record) {
- this.maxLeverage = Number(options.maxLeverage ?? DEFAULT_MAX_LEVERAGE);
- this.symbolOverrides = (options.symbolOverrides as Record) ?? {};
- }
-
- check(ctx: GuardContext): string | null {
- const { operation } = ctx;
-
- let leverage: number | undefined;
- let symbol: string | undefined;
-
- if (operation.action === 'placeOrder') {
- leverage = operation.params.leverage as number | undefined;
- symbol = operation.params.symbol as string;
- } else if (operation.action === 'adjustLeverage') {
- leverage = operation.params.newLeverage as number;
- symbol = operation.params.symbol as string;
- }
-
- if (leverage == null || symbol == null) return null;
-
- const limit = this.symbolOverrides[symbol] ?? this.maxLeverage;
-
- if (leverage > limit) {
- return `Leverage ${leverage}x exceeds limit ${limit}x for ${symbol}`;
- }
-
- return null;
- }
-}
diff --git a/src/extension/crypto-trading/guards/max-position-size.spec.ts b/src/extension/crypto-trading/guards/max-position-size.spec.ts
deleted file mode 100644
index 3e6c3115..00000000
--- a/src/extension/crypto-trading/guards/max-position-size.spec.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { MaxPositionSizeGuard } from './max-position-size.js';
-import type { GuardContext } from './types.js';
-import type { CryptoPosition, CryptoAccountInfo } from '../interfaces.js';
-
-function makeCtx(overrides: {
- action?: string;
- params?: Record;
- positions?: CryptoPosition[];
- account?: Partial;
-} = {}): GuardContext {
- return {
- operation: {
- action: (overrides.action ?? 'placeOrder') as 'placeOrder',
- params: overrides.params ?? { symbol: 'BTC/USD', side: 'buy', type: 'market', usd_size: 1000 },
- },
- positions: overrides.positions ?? [],
- account: {
- balance: 10000, totalMargin: 0, unrealizedPnL: 0,
- equity: 10000, realizedPnL: 0, totalPnL: 0,
- ...overrides.account,
- },
- };
-}
-
-describe('MaxPositionSizeGuard', () => {
- it('allows placeOrder within limit', () => {
- const guard = new MaxPositionSizeGuard({ maxPercentOfEquity: 25 });
- const ctx = makeCtx({ params: { symbol: 'BTC/USD', side: 'buy', type: 'market', usd_size: 2000 } });
-
- expect(guard.check(ctx)).toBeNull();
- });
-
- it('rejects placeOrder exceeding limit', () => {
- const guard = new MaxPositionSizeGuard({ maxPercentOfEquity: 25 });
- const ctx = makeCtx({ params: { symbol: 'BTC/USD', side: 'buy', type: 'market', usd_size: 3000 } });
-
- expect(guard.check(ctx)).toContain('30.0%');
- expect(guard.check(ctx)).toContain('limit: 25%');
- });
-
- it('accounts for existing position value', () => {
- const guard = new MaxPositionSizeGuard({ maxPercentOfEquity: 25 });
- const positions: CryptoPosition[] = [{
- symbol: 'BTC/USD', side: 'long', size: 0.01, entryPrice: 95000,
- leverage: 1, margin: 950, liquidationPrice: 0,
- markPrice: 95000, unrealizedPnL: 0, positionValue: 2000,
- }];
- // existing 2000 + new 1000 = 3000 = 30% of 10000 equity → reject
- const ctx = makeCtx({
- params: { symbol: 'BTC/USD', side: 'buy', type: 'market', usd_size: 1000 },
- positions,
- });
-
- expect(guard.check(ctx)).not.toBeNull();
- });
-
- it('ignores non-placeOrder actions', () => {
- const guard = new MaxPositionSizeGuard({ maxPercentOfEquity: 10 });
- const ctx = makeCtx({ action: 'cancelOrder', params: { orderId: 'abc' } });
-
- expect(guard.check(ctx)).toBeNull();
- });
-
- it('allows when added value cannot be estimated (coin size, no existing position)', () => {
- const guard = new MaxPositionSizeGuard({ maxPercentOfEquity: 5 });
- const ctx = makeCtx({ params: { symbol: 'BTC/USD', side: 'buy', type: 'market', size: 10 } });
-
- expect(guard.check(ctx)).toBeNull();
- });
-
- it('uses default 25% when no config provided', () => {
- const guard = new MaxPositionSizeGuard({});
- // 2600 / 10000 = 26% > 25%
- const ctx = makeCtx({ params: { symbol: 'BTC/USD', side: 'buy', type: 'market', usd_size: 2600 } });
-
- expect(guard.check(ctx)).not.toBeNull();
- });
-});
diff --git a/src/extension/crypto-trading/guards/max-position-size.ts b/src/extension/crypto-trading/guards/max-position-size.ts
deleted file mode 100644
index 05cffc84..00000000
--- a/src/extension/crypto-trading/guards/max-position-size.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import type { OperationGuard, GuardContext } from './types.js';
-
-const DEFAULT_MAX_PERCENT = 25;
-
-export class MaxPositionSizeGuard implements OperationGuard {
- readonly name = 'max-position-size';
- private maxPercent: number;
-
- constructor(options: Record) {
- this.maxPercent = Number(options.maxPercentOfEquity ?? DEFAULT_MAX_PERCENT);
- }
-
- check(ctx: GuardContext): string | null {
- if (ctx.operation.action !== 'placeOrder') return null;
-
- const { positions, account, operation } = ctx;
- const symbol = operation.params.symbol as string;
-
- const existing = positions.find(p => p.symbol === symbol);
- const currentValue = existing?.positionValue ?? 0;
-
- // Estimate added value
- const usdSize = operation.params.usd_size as number | undefined;
- const size = operation.params.size as number | undefined;
-
- let addedValue = 0;
- if (usdSize) {
- addedValue = usdSize;
- } else if (size && existing) {
- addedValue = size * existing.markPrice;
- }
- // If we can't estimate (new symbol + coin-based), allow — engine will handle
-
- if (addedValue === 0) return null;
-
- const projectedValue = currentValue + addedValue;
- const percent = account.equity > 0 ? (projectedValue / account.equity) * 100 : 0;
-
- if (percent > this.maxPercent) {
- return `Position for ${symbol} would be ${percent.toFixed(1)}% of equity (limit: ${this.maxPercent}%)`;
- }
-
- return null;
- }
-}
diff --git a/src/extension/crypto-trading/guards/symbol-whitelist.spec.ts b/src/extension/crypto-trading/guards/symbol-whitelist.spec.ts
deleted file mode 100644
index 26d9638d..00000000
--- a/src/extension/crypto-trading/guards/symbol-whitelist.spec.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { SymbolWhitelistGuard } from './symbol-whitelist.js';
-import type { GuardContext } from './types.js';
-
-function makeCtx(symbol = 'BTC/USD', action = 'placeOrder'): GuardContext {
- return {
- operation: { action: action as 'placeOrder', params: { symbol, side: 'buy', type: 'market', usd_size: 1000 } },
- positions: [],
- account: { balance: 10000, totalMargin: 0, unrealizedPnL: 0, equity: 10000, realizedPnL: 0, totalPnL: 0 },
- };
-}
-
-describe('SymbolWhitelistGuard', () => {
- it('allows whitelisted symbol', () => {
- const guard = new SymbolWhitelistGuard({ symbols: ['BTC/USD', 'ETH/USD'] });
- expect(guard.check(makeCtx('BTC/USD'))).toBeNull();
- });
-
- it('rejects non-whitelisted symbol', () => {
- const guard = new SymbolWhitelistGuard({ symbols: ['BTC/USD', 'ETH/USD'] });
- const result = guard.check(makeCtx('DOGE/USD'));
- expect(result).toContain('DOGE/USD');
- expect(result).toContain('not in the allowed list');
- });
-
- it('allows operations without a symbol param', () => {
- const guard = new SymbolWhitelistGuard({ symbols: ['BTC/USD'] });
- const ctx: GuardContext = {
- operation: { action: 'cancelOrder', params: { orderId: 'abc' } },
- positions: [],
- account: { balance: 10000, totalMargin: 0, unrealizedPnL: 0, equity: 10000, realizedPnL: 0, totalPnL: 0 },
- };
- expect(guard.check(ctx)).toBeNull();
- });
-
- it('throws when constructed without symbols', () => {
- expect(() => new SymbolWhitelistGuard({})).toThrow('non-empty "symbols" array');
- });
-
- it('throws when constructed with empty symbols', () => {
- expect(() => new SymbolWhitelistGuard({ symbols: [] })).toThrow('non-empty "symbols" array');
- });
-
- it('checks closePosition operations too', () => {
- const guard = new SymbolWhitelistGuard({ symbols: ['BTC/USD'] });
- expect(guard.check(makeCtx('ETH/USD', 'closePosition'))).not.toBeNull();
- });
-
- it('checks adjustLeverage operations too', () => {
- const guard = new SymbolWhitelistGuard({ symbols: ['BTC/USD'] });
- expect(guard.check(makeCtx('ETH/USD', 'adjustLeverage'))).not.toBeNull();
- });
-});
diff --git a/src/extension/crypto-trading/guards/types.ts b/src/extension/crypto-trading/guards/types.ts
deleted file mode 100644
index e93bd4c4..00000000
--- a/src/extension/crypto-trading/guards/types.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import type { Operation } from '../wallet/types.js';
-import type { CryptoPosition, CryptoAccountInfo } from '../interfaces.js';
-
-/** Read-only context assembled by the pipeline, consumed by guards */
-export interface GuardContext {
- readonly operation: Operation;
- readonly positions: readonly CryptoPosition[];
- readonly account: Readonly;
-}
-
-/** A guard that can reject operations. Returns null to allow, or a rejection reason string. */
-export interface OperationGuard {
- readonly name: string;
- check(ctx: GuardContext): Promise | string | null;
-}
-
-/** Registry entry: type identifier + factory function */
-export interface GuardRegistryEntry {
- type: string;
- create(options: Record): OperationGuard;
-}
diff --git a/src/extension/crypto-trading/index.ts b/src/extension/crypto-trading/index.ts
deleted file mode 100644
index 769f0907..00000000
--- a/src/extension/crypto-trading/index.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-// Extension adapter
-export { createCryptoTradingTools } from './adapter';
-
-// Trading domain types
-export type {
- ICryptoTradingEngine,
- CryptoPlaceOrderRequest,
- CryptoOrderResult,
- CryptoOrder,
- CryptoPosition,
- CryptoAccountInfo,
- SymbolPrecision,
-} from './interfaces';
-
-// Wallet domain
-export { Wallet } from './wallet/Wallet';
-export type { IWallet, WalletConfig } from './wallet/interfaces';
-export type {
- Operation,
- WalletCommit,
- WalletExportState,
- CommitHash,
- OrderStatusUpdate,
- SyncResult,
-} from './wallet/types';
-
-// Provider infrastructure
-export { createCryptoTradingEngine } from './factory';
-export type { CryptoTradingEngineResult } from './factory';
-export { createCryptoOperationDispatcher } from './operation-dispatcher';
-export { createCryptoWalletStateBridge } from './wallet-state-bridge';
-
-// Guard system
-export { createGuardPipeline, resolveGuards, registerGuard } from './guards/index';
-export type { OperationGuard, GuardContext, GuardRegistryEntry } from './guards/index';
diff --git a/src/extension/crypto-trading/interfaces.ts b/src/extension/crypto-trading/interfaces.ts
deleted file mode 100644
index 1b799eac..00000000
--- a/src/extension/crypto-trading/interfaces.ts
+++ /dev/null
@@ -1,124 +0,0 @@
-/**
- * Crypto Trading Engine interface definitions
- *
- * Only defines interfaces and data types; implementation is provided by external trading services
- */
-
-// ==================== Core interfaces ====================
-
-export interface ICryptoTradingEngine {
- placeOrder(order: CryptoPlaceOrderRequest, currentTime?: Date): Promise;
- getPositions(): Promise;
- getOrders(): Promise;
- getAccount(): Promise;
- cancelOrder(orderId: string): Promise;
- adjustLeverage(symbol: string, newLeverage: number): Promise<{ success: boolean; error?: string }>;
- getTicker(symbol: string): Promise;
- getFundingRate(symbol: string): Promise;
- getOrderBook(symbol: string, limit?: number): Promise;
-}
-
-// ==================== Orders ====================
-
-export interface CryptoPlaceOrderRequest {
- symbol: string;
- side: 'buy' | 'sell';
- type: 'market' | 'limit';
- size?: number;
- usd_size?: number;
- price?: number;
- leverage?: number;
- reduceOnly?: boolean;
-}
-
-export interface CryptoOrderResult {
- success: boolean;
- orderId?: string;
- error?: string;
- message?: string;
- filledPrice?: number;
- filledSize?: number;
-}
-
-export interface CryptoOrder {
- id: string;
- symbol: string;
- side: 'buy' | 'sell';
- type: 'market' | 'limit';
- size: number;
- price?: number;
- leverage?: number;
- reduceOnly?: boolean;
- status: 'pending' | 'filled' | 'cancelled' | 'rejected';
- filledPrice?: number;
- filledSize?: number;
- filledAt?: Date;
- createdAt: Date;
- rejectReason?: string;
-}
-
-// ==================== Positions ====================
-
-export interface CryptoPosition {
- symbol: string;
- side: 'long' | 'short';
- size: number;
- entryPrice: number;
- leverage: number;
- margin: number;
- liquidationPrice: number;
- markPrice: number;
- unrealizedPnL: number;
- positionValue: number;
-}
-
-// ==================== Account ====================
-
-export interface CryptoAccountInfo {
- balance: number;
- totalMargin: number;
- unrealizedPnL: number;
- equity: number;
- realizedPnL: number;
- totalPnL: number;
-}
-
-// ==================== Market data ====================
-
-export interface CryptoTicker {
- symbol: string;
- last: number;
- bid: number;
- ask: number;
- high: number;
- low: number;
- volume: number;
- timestamp: Date;
-}
-
-export interface CryptoFundingRate {
- symbol: string;
- fundingRate: number;
- nextFundingTime?: Date;
- previousFundingRate?: number;
- timestamp: Date;
-}
-
-// ==================== Order Book ====================
-
-/** A single price level in the order book: [price, amount] */
-export type CryptoOrderBookLevel = [price: number, amount: number];
-
-export interface CryptoOrderBook {
- symbol: string;
- bids: CryptoOrderBookLevel[];
- asks: CryptoOrderBookLevel[];
- timestamp: Date;
-}
-
-// ==================== Precision ====================
-
-export interface SymbolPrecision {
- price: number;
- size: number;
-}
diff --git a/src/extension/crypto-trading/operation-dispatcher.spec.ts b/src/extension/crypto-trading/operation-dispatcher.spec.ts
deleted file mode 100644
index 1f146cdd..00000000
--- a/src/extension/crypto-trading/operation-dispatcher.spec.ts
+++ /dev/null
@@ -1,312 +0,0 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { createCryptoOperationDispatcher } from './operation-dispatcher.js';
-import type { ICryptoTradingEngine, CryptoPosition } from './interfaces.js';
-import type { Operation } from './wallet/types.js';
-
-// ==================== Mock Factory ====================
-
-function createMockEngine(overrides: Partial = {}): ICryptoTradingEngine {
- return {
- placeOrder: vi.fn().mockResolvedValue({
- success: true,
- orderId: 'ord-001',
- filledPrice: 95000,
- filledSize: 0.1,
- }),
- getPositions: vi.fn().mockResolvedValue([]),
- getOrders: vi.fn().mockResolvedValue([]),
- getAccount: vi.fn().mockResolvedValue({
- balance: 10000, totalMargin: 0, unrealizedPnL: 0,
- equity: 10000, realizedPnL: 0, totalPnL: 0,
- }),
- cancelOrder: vi.fn().mockResolvedValue(true),
- adjustLeverage: vi.fn().mockResolvedValue({ success: true }),
- getTicker: vi.fn().mockResolvedValue({
- symbol: 'BTC/USD', last: 95000, bid: 94999, ask: 95001,
- high: 96000, low: 94000, volume: 1000, timestamp: new Date(),
- }),
- getFundingRate: vi.fn().mockResolvedValue({
- symbol: 'BTC/USD', fundingRate: 0.0001, timestamp: new Date(),
- }),
- getOrderBook: vi.fn().mockResolvedValue({
- symbol: 'BTC/USD', bids: [], asks: [], timestamp: new Date(),
- }),
- ...overrides,
- };
-}
-
-function makeLongPosition(overrides: Partial = {}): CryptoPosition {
- return {
- symbol: 'BTC/USD', side: 'long', size: 0.5, entryPrice: 90000,
- leverage: 5, margin: 9000, liquidationPrice: 72000,
- markPrice: 95000, unrealizedPnL: 2500, positionValue: 47500,
- ...overrides,
- };
-}
-
-function makeShortPosition(overrides: Partial = {}): CryptoPosition {
- return {
- symbol: 'ETH/USD', side: 'short', size: 10, entryPrice: 3500,
- leverage: 3, margin: 11667, liquidationPrice: 4500,
- markPrice: 3400, unrealizedPnL: 1000, positionValue: 34000,
- ...overrides,
- };
-}
-
-// ==================== Tests ====================
-
-describe('createCryptoOperationDispatcher', () => {
- let engine: ICryptoTradingEngine;
- let dispatch: (op: Operation) => Promise;
-
- beforeEach(() => {
- engine = createMockEngine();
- dispatch = createCryptoOperationDispatcher(engine);
- });
-
- // ==================== placeOrder ====================
-
- describe('placeOrder', () => {
- it('maps Operation params to CryptoPlaceOrderRequest', async () => {
- const op: Operation = {
- action: 'placeOrder',
- params: {
- symbol: 'BTC/USD', side: 'buy', type: 'limit',
- size: 0.5, price: 90000, leverage: 10, reduceOnly: false,
- },
- };
-
- await dispatch(op);
-
- expect(engine.placeOrder).toHaveBeenCalledWith({
- symbol: 'BTC/USD', side: 'buy', type: 'limit',
- size: 0.5, usd_size: undefined, price: 90000,
- leverage: 10, reduceOnly: false,
- });
- });
-
- it('passes usd_size when size is not provided', async () => {
- const op: Operation = {
- action: 'placeOrder',
- params: { symbol: 'BTC/USD', side: 'buy', type: 'market', usd_size: 1000 },
- };
-
- await dispatch(op);
-
- expect(engine.placeOrder).toHaveBeenCalledWith(
- expect.objectContaining({ size: undefined, usd_size: 1000 }),
- );
- });
-
- it('wraps successful filled result', async () => {
- const op: Operation = {
- action: 'placeOrder',
- params: { symbol: 'BTC/USD', side: 'buy', type: 'market' },
- };
-
- const result = await dispatch(op);
-
- expect(result).toEqual({
- success: true,
- error: undefined,
- order: {
- id: 'ord-001',
- status: 'filled',
- filledPrice: 95000,
- filledQuantity: 0.1,
- },
- });
- });
-
- it('wraps successful pending result (no filledPrice)', async () => {
- engine = createMockEngine({
- placeOrder: vi.fn().mockResolvedValue({
- success: true, orderId: 'ord-002',
- filledPrice: undefined, filledSize: undefined,
- }),
- });
- dispatch = createCryptoOperationDispatcher(engine);
-
- const result = await dispatch({
- action: 'placeOrder',
- params: { symbol: 'BTC/USD', side: 'buy', type: 'limit', price: 90000 },
- });
-
- expect(result).toEqual({
- success: true,
- error: undefined,
- order: {
- id: 'ord-002',
- status: 'pending',
- filledPrice: undefined,
- filledQuantity: undefined,
- },
- });
- });
-
- it('wraps failed result with error', async () => {
- engine = createMockEngine({
- placeOrder: vi.fn().mockResolvedValue({
- success: false, error: 'Insufficient balance',
- }),
- });
- dispatch = createCryptoOperationDispatcher(engine);
-
- const result = await dispatch({
- action: 'placeOrder',
- params: { symbol: 'BTC/USD', side: 'buy', type: 'market', size: 100 },
- });
-
- expect(result).toEqual({
- success: false,
- error: 'Insufficient balance',
- order: undefined,
- });
- });
- });
-
- // ==================== closePosition ====================
-
- describe('closePosition', () => {
- it('places sell order with reduceOnly for long position', async () => {
- engine = createMockEngine({
- getPositions: vi.fn().mockResolvedValue([makeLongPosition()]),
- });
- dispatch = createCryptoOperationDispatcher(engine);
-
- await dispatch({ action: 'closePosition', params: { symbol: 'BTC/USD' } });
-
- expect(engine.placeOrder).toHaveBeenCalledWith({
- symbol: 'BTC/USD', side: 'sell', type: 'market',
- size: 0.5, reduceOnly: true,
- });
- });
-
- it('places buy order with reduceOnly for short position', async () => {
- engine = createMockEngine({
- getPositions: vi.fn().mockResolvedValue([makeShortPosition()]),
- });
- dispatch = createCryptoOperationDispatcher(engine);
-
- await dispatch({ action: 'closePosition', params: { symbol: 'ETH/USD' } });
-
- expect(engine.placeOrder).toHaveBeenCalledWith({
- symbol: 'ETH/USD', side: 'buy', type: 'market',
- size: 10, reduceOnly: true,
- });
- });
-
- it('uses specified partial size', async () => {
- engine = createMockEngine({
- getPositions: vi.fn().mockResolvedValue([makeLongPosition()]),
- });
- dispatch = createCryptoOperationDispatcher(engine);
-
- await dispatch({ action: 'closePosition', params: { symbol: 'BTC/USD', size: 0.2 } });
-
- expect(engine.placeOrder).toHaveBeenCalledWith(
- expect.objectContaining({ size: 0.2 }),
- );
- });
-
- it('returns error when no position exists', async () => {
- const result = await dispatch({
- action: 'closePosition', params: { symbol: 'BTC/USD' },
- });
-
- expect(result).toEqual({
- success: false,
- error: 'No open position for BTC/USD',
- });
- expect(engine.placeOrder).not.toHaveBeenCalled();
- });
-
- it('wraps the placeOrder result in standard format', async () => {
- engine = createMockEngine({
- getPositions: vi.fn().mockResolvedValue([makeLongPosition()]),
- });
- dispatch = createCryptoOperationDispatcher(engine);
-
- const result = await dispatch({
- action: 'closePosition', params: { symbol: 'BTC/USD' },
- });
-
- expect(result).toEqual({
- success: true,
- error: undefined,
- order: {
- id: 'ord-001',
- status: 'filled',
- filledPrice: 95000,
- filledQuantity: 0.1,
- },
- });
- });
- });
-
- // ==================== cancelOrder ====================
-
- describe('cancelOrder', () => {
- it('returns success when cancellation succeeds', async () => {
- const result = await dispatch({
- action: 'cancelOrder', params: { orderId: 'ord-001' },
- });
-
- expect(engine.cancelOrder).toHaveBeenCalledWith('ord-001');
- expect(result).toEqual({ success: true, error: undefined });
- });
-
- it('returns error when cancellation fails', async () => {
- engine = createMockEngine({
- cancelOrder: vi.fn().mockResolvedValue(false),
- });
- dispatch = createCryptoOperationDispatcher(engine);
-
- const result = await dispatch({
- action: 'cancelOrder', params: { orderId: 'ord-999' },
- });
-
- expect(result).toEqual({ success: false, error: 'Failed to cancel order' });
- });
- });
-
- // ==================== adjustLeverage ====================
-
- describe('adjustLeverage', () => {
- it('passes through to engine.adjustLeverage', async () => {
- const result = await dispatch({
- action: 'adjustLeverage',
- params: { symbol: 'BTC/USD', newLeverage: 10 },
- });
-
- expect(engine.adjustLeverage).toHaveBeenCalledWith('BTC/USD', 10);
- expect(result).toEqual({ success: true });
- });
-
- it('returns error from engine', async () => {
- engine = createMockEngine({
- adjustLeverage: vi.fn().mockResolvedValue({
- success: false, error: 'Leverage too high',
- }),
- });
- dispatch = createCryptoOperationDispatcher(engine);
-
- const result = await dispatch({
- action: 'adjustLeverage',
- params: { symbol: 'BTC/USD', newLeverage: 125 },
- });
-
- expect(result).toEqual({ success: false, error: 'Leverage too high' });
- });
- });
-
- // ==================== unknown action ====================
-
- describe('unknown action', () => {
- it('throws for unknown action', async () => {
- await expect(
- dispatch({ action: 'syncOrders', params: {} }),
- ).rejects.toThrow('Unknown operation action: syncOrders');
- });
- });
-});
diff --git a/src/extension/crypto-trading/operation-dispatcher.ts b/src/extension/crypto-trading/operation-dispatcher.ts
deleted file mode 100644
index 4e4c5335..00000000
--- a/src/extension/crypto-trading/operation-dispatcher.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-/**
- * Crypto Operation Dispatcher
- *
- * Provider-agnostic bridge: Wallet Operation -> ICryptoTradingEngine method calls
- * Used as the WalletConfig.executeOperation callback
- *
- * Return values must match the structure expected by Wallet.parseOperationResult (Wallet.ts):
- * - placeOrder: { success, order?: { id, status, filledPrice, filledQuantity } }
- * - Others: { success, error? }
- */
-
-import type { ICryptoTradingEngine, CryptoPlaceOrderRequest } from './interfaces.js';
-import type { Operation } from './wallet/types.js';
-
-export function createCryptoOperationDispatcher(engine: ICryptoTradingEngine) {
- return async (op: Operation): Promise => {
- switch (op.action) {
- case 'placeOrder': {
- const req: CryptoPlaceOrderRequest = {
- symbol: op.params.symbol as string,
- side: op.params.side as 'buy' | 'sell',
- type: op.params.type as 'market' | 'limit',
- size: op.params.size as number | undefined,
- usd_size: op.params.usd_size as number | undefined,
- price: op.params.price as number | undefined,
- leverage: op.params.leverage as number | undefined,
- reduceOnly: op.params.reduceOnly as boolean | undefined,
- };
-
- const result = await engine.placeOrder(req);
-
- // Wrap into the format expected by parseOperationResult
- return {
- success: result.success,
- error: result.error,
- order: result.success
- ? {
- id: result.orderId,
- status: result.filledPrice ? 'filled' : 'pending',
- filledPrice: result.filledPrice,
- filledQuantity: result.filledSize,
- }
- : undefined,
- };
- }
-
- case 'closePosition': {
- const symbol = op.params.symbol as string;
- const size = op.params.size as number | undefined;
-
- // Look up the current position and place a reverse order to close
- const positions = await engine.getPositions();
- const position = positions.find(p => p.symbol === symbol);
-
- if (!position) {
- return { success: false, error: `No open position for ${symbol}` };
- }
-
- const closeSide = position.side === 'long' ? 'sell' : 'buy';
- const closeSize = size ?? position.size;
-
- const result = await engine.placeOrder({
- symbol,
- side: closeSide,
- type: 'market',
- size: closeSize,
- reduceOnly: true,
- });
-
- return {
- success: result.success,
- error: result.error,
- order: result.success
- ? {
- id: result.orderId,
- status: result.filledPrice ? 'filled' : 'pending',
- filledPrice: result.filledPrice,
- filledQuantity: result.filledSize,
- }
- : undefined,
- };
- }
-
- case 'cancelOrder': {
- const orderId = op.params.orderId as string;
- const success = await engine.cancelOrder(orderId);
- return { success, error: success ? undefined : 'Failed to cancel order' };
- }
-
- case 'adjustLeverage': {
- const symbol = op.params.symbol as string;
- const newLeverage = op.params.newLeverage as number;
- return await engine.adjustLeverage(symbol, newLeverage);
- }
-
- default:
- throw new Error(`Unknown operation action: ${op.action}`);
- }
- };
-}
diff --git a/src/extension/crypto-trading/providers/ccxt/CcxtTradingEngine.ts b/src/extension/crypto-trading/providers/ccxt/CcxtTradingEngine.ts
deleted file mode 100644
index b32728df..00000000
--- a/src/extension/crypto-trading/providers/ccxt/CcxtTradingEngine.ts
+++ /dev/null
@@ -1,365 +0,0 @@
-/**
- * CCXT Trading Engine
- *
- * CCXT implementation of ICryptoTradingEngine, connecting to 100+ exchanges via ccxt unified API
- * No polling/waiting; placeOrder returns the exchange's immediate response directly
- */
-
-import ccxt from 'ccxt';
-import type { Exchange, Order as CcxtOrder } from 'ccxt';
-import type {
- ICryptoTradingEngine,
- CryptoPlaceOrderRequest,
- CryptoOrderResult,
- CryptoPosition,
- CryptoOrder,
- CryptoAccountInfo,
- CryptoTicker,
- CryptoFundingRate,
- CryptoOrderBook,
- CryptoOrderBookLevel,
-} from '../../interfaces.js';
-import { SymbolMapper } from './symbol-map.js';
-
-export interface CcxtEngineConfig {
- exchange: string;
- apiKey: string;
- apiSecret: string;
- password?: string;
- sandbox: boolean;
- demoTrading?: boolean;
- defaultMarketType: 'spot' | 'swap';
- options?: Record;
-}
-
-export class CcxtTradingEngine implements ICryptoTradingEngine {
- private exchange: Exchange;
- private symbolMapper: SymbolMapper;
- private initialized = false;
-
- // Maintain orderId -> ccxtSymbol mapping for cancelOrder
- private orderSymbolCache = new Map();
-
- constructor(private config: CcxtEngineConfig) {
- const exchanges = ccxt as unknown as Record) => Exchange>;
- const ExchangeClass = exchanges[config.exchange];
- if (!ExchangeClass) {
- throw new Error(`Unknown CCXT exchange: ${config.exchange}`);
- }
-
- this.exchange = new ExchangeClass({
- apiKey: config.apiKey,
- secret: config.apiSecret,
- password: config.password,
- options: config.options,
- });
-
- if (config.sandbox) {
- this.exchange.setSandboxMode(true);
- }
-
- if (config.demoTrading) {
- (this.exchange as unknown as { enableDemoTrading: (enable: boolean) => void }).enableDemoTrading(true);
- }
-
- this.symbolMapper = new SymbolMapper(
- config.defaultMarketType,
- );
- }
-
- async init(): Promise {
- await this.exchange.loadMarkets();
- this.symbolMapper.init(this.exchange.markets as unknown as Record);
- this.initialized = true;
- }
-
- // ==================== ICryptoTradingEngine ====================
-
- async placeOrder(order: CryptoPlaceOrderRequest, _currentTime?: Date): Promise {
- this.ensureInit();
-
- const ccxtSymbol = this.symbolMapper.toCcxt(order.symbol);
- let size = order.size;
-
- // usd_size -> coin size conversion
- if (!size && order.usd_size) {
- const ticker = await this.exchange.fetchTicker(ccxtSymbol);
- const price = order.price ?? ticker.last;
- if (!price) {
- return { success: false, error: 'Cannot determine price for USD size conversion' };
- }
- size = order.usd_size / price;
- }
-
- if (!size) {
- return { success: false, error: 'Either size or usd_size must be provided' };
- }
-
- try {
- // Futures: set leverage first
- if (order.leverage && order.leverage > 1) {
- try {
- await this.exchange.setLeverage(order.leverage, ccxtSymbol);
- } catch {
- // Some exchanges don't support setLeverage or leverage is already set; ignore
- }
- }
-
- const params: Record = {};
- if (order.reduceOnly) params.reduceOnly = true;
-
- const ccxtOrder = await this.exchange.createOrder(
- ccxtSymbol,
- order.type,
- order.side,
- size,
- order.type === 'limit' ? order.price : undefined,
- params,
- );
-
- // Cache orderId -> symbol mapping
- if (ccxtOrder.id) {
- this.orderSymbolCache.set(ccxtOrder.id, ccxtSymbol);
- }
-
- const status = this.mapOrderStatus(ccxtOrder.status);
-
- return {
- success: true,
- orderId: ccxtOrder.id,
- message: `Order ${ccxtOrder.id} ${status}`,
- filledPrice: status === 'filled' ? (ccxtOrder.average ?? ccxtOrder.price ?? undefined) : undefined,
- filledSize: status === 'filled' ? (ccxtOrder.filled ?? undefined) : undefined,
- };
- } catch (err) {
- return {
- success: false,
- error: err instanceof Error ? err.message : String(err),
- };
- }
- }
-
- async getPositions(): Promise {
- this.ensureInit();
-
- const raw = await this.exchange.fetchPositions();
- const result: CryptoPosition[] = [];
-
- for (const p of raw) {
- const internalSymbol = this.symbolMapper.tryToInternal(p.symbol);
- if (!internalSymbol) continue;
-
- const size = Math.abs(parseFloat(String(p.contracts ?? 0)) * parseFloat(String(p.contractSize ?? 1)));
- if (size === 0) continue;
-
- result.push({
- symbol: internalSymbol,
- side: p.side === 'long' ? 'long' : 'short',
- size,
- entryPrice: parseFloat(String(p.entryPrice ?? 0)),
- leverage: parseFloat(String(p.leverage ?? 1)),
- margin: parseFloat(String(p.initialMargin ?? p.collateral ?? 0)),
- liquidationPrice: parseFloat(String(p.liquidationPrice ?? 0)),
- markPrice: parseFloat(String(p.markPrice ?? 0)),
- unrealizedPnL: parseFloat(String(p.unrealizedPnl ?? 0)),
- positionValue: size * parseFloat(String(p.markPrice ?? 0)),
- });
- }
-
- return result;
- }
-
- async getOrders(): Promise {
- this.ensureInit();
-
- const allOrders: CcxtOrder[] = [];
-
- try {
- const open = await this.exchange.fetchOpenOrders();
- allOrders.push(...open);
- } catch {
- // Some exchanges don't support fetchOpenOrders
- }
-
- try {
- const closed = await this.exchange.fetchClosedOrders(undefined, undefined, 50);
- allOrders.push(...closed);
- } catch {
- // Some exchanges don't support fetchClosedOrders
- }
-
- const result: CryptoOrder[] = [];
-
- for (const o of allOrders) {
- const internalSymbol = this.symbolMapper.tryToInternal(o.symbol);
- if (!internalSymbol) continue;
-
- // Cache orderId -> symbol
- if (o.id) {
- this.orderSymbolCache.set(o.id, o.symbol);
- }
-
- result.push({
- id: o.id,
- symbol: internalSymbol,
- side: o.side as CryptoOrder['side'],
- type: (o.type ?? 'market') as CryptoOrder['type'],
- size: o.amount ?? 0,
- price: o.price,
- leverage: undefined,
- reduceOnly: o.reduceOnly ?? false,
- status: this.mapOrderStatus(o.status),
- filledPrice: o.average,
- filledSize: o.filled,
- filledAt: o.lastTradeTimestamp ? new Date(o.lastTradeTimestamp) : undefined,
- createdAt: new Date(o.timestamp ?? Date.now()),
- });
- }
-
- return result;
- }
-
- async getAccount(): Promise {
- this.ensureInit();
-
- const [balance, rawPositions] = await Promise.all([
- this.exchange.fetchBalance(),
- this.exchange.fetchPositions(),
- ]);
-
- // CCXT Balance uses indexer to access currency
- const bal = balance as unknown as Record>;
- const total = parseFloat(String(bal['total']?.['USDT'] ?? bal['total']?.['USD'] ?? 0));
- const free = parseFloat(String(bal['free']?.['USDT'] ?? bal['free']?.['USD'] ?? 0));
- const used = parseFloat(String(bal['used']?.['USDT'] ?? bal['used']?.['USD'] ?? 0));
-
- // Aggregate PnL from raw positions
- let unrealizedPnL = 0;
- let realizedPnL = 0;
- for (const p of rawPositions) {
- if (!this.symbolMapper.tryToInternal(p.symbol)) continue;
- unrealizedPnL += parseFloat(String(p.unrealizedPnl ?? 0));
- realizedPnL += parseFloat(String((p as unknown as Record).realizedPnl ?? 0));
- }
-
- return {
- balance: free,
- totalMargin: used,
- unrealizedPnL,
- equity: total,
- realizedPnL,
- totalPnL: realizedPnL + unrealizedPnL,
- };
- }
-
- async cancelOrder(orderId: string): Promise {
- this.ensureInit();
-
- try {
- const ccxtSymbol = this.orderSymbolCache.get(orderId);
- await this.exchange.cancelOrder(orderId, ccxtSymbol);
- return true;
- } catch {
- return false;
- }
- }
-
- async adjustLeverage(
- symbol: string,
- newLeverage: number,
- ): Promise<{ success: boolean; error?: string }> {
- this.ensureInit();
-
- const ccxtSymbol = this.symbolMapper.toCcxt(symbol);
- try {
- await this.exchange.setLeverage(newLeverage, ccxtSymbol);
- return { success: true };
- } catch (err) {
- return {
- success: false,
- error: err instanceof Error ? err.message : String(err),
- };
- }
- }
-
- async getTicker(symbol: string): Promise {
- this.ensureInit();
-
- const ccxtSymbol = this.symbolMapper.toCcxt(symbol);
- const ticker = await this.exchange.fetchTicker(ccxtSymbol);
-
- return {
- symbol,
- last: ticker.last ?? 0,
- bid: ticker.bid ?? 0,
- ask: ticker.ask ?? 0,
- high: ticker.high ?? 0,
- low: ticker.low ?? 0,
- volume: ticker.baseVolume ?? 0,
- timestamp: new Date(ticker.timestamp ?? Date.now()),
- };
- }
-
- async getFundingRate(symbol: string): Promise {
- this.ensureInit();
-
- const ccxtSymbol = this.symbolMapper.toCcxt(symbol);
- const funding = await this.exchange.fetchFundingRate(ccxtSymbol);
-
- return {
- symbol,
- fundingRate: funding.fundingRate ?? 0,
- nextFundingTime: funding.fundingDatetime
- ? new Date(funding.fundingDatetime)
- : undefined,
- previousFundingRate: funding.previousFundingRate ?? undefined,
- timestamp: new Date(funding.timestamp ?? Date.now()),
- };
- }
-
- async getOrderBook(symbol: string, limit?: number): Promise {
- this.ensureInit();
-
- const ccxtSymbol = this.symbolMapper.toCcxt(symbol);
- const book = await this.exchange.fetchOrderBook(ccxtSymbol, limit);
-
- return {
- symbol,
- bids: book.bids.map(([p, a]) => [p ?? 0, a ?? 0] as CryptoOrderBookLevel),
- asks: book.asks.map(([p, a]) => [p ?? 0, a ?? 0] as CryptoOrderBookLevel),
- timestamp: new Date(book.timestamp ?? Date.now()),
- };
- }
-
- // ==================== Helpers ====================
-
- private ensureInit(): void {
- if (!this.initialized) {
- throw new Error('CcxtTradingEngine not initialized. Call init() first.');
- }
- }
-
- private mapOrderStatus(status: string | undefined): CryptoOrder['status'] {
- switch (status) {
- case 'closed': return 'filled';
- case 'open': return 'pending';
- case 'canceled':
- case 'cancelled': return 'cancelled';
- case 'expired':
- case 'rejected': return 'rejected';
- default: return 'pending';
- }
- }
-
- async close(): Promise {
- // ccxt exchanges typically don't need explicit closing
- }
-}
diff --git a/src/extension/crypto-trading/providers/ccxt/index.ts b/src/extension/crypto-trading/providers/ccxt/index.ts
deleted file mode 100644
index 2714eef5..00000000
--- a/src/extension/crypto-trading/providers/ccxt/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export { CcxtTradingEngine } from './CcxtTradingEngine.js';
-export type { CcxtEngineConfig } from './CcxtTradingEngine.js';
-export { SymbolMapper } from './symbol-map.js';
diff --git a/src/extension/crypto-trading/providers/ccxt/symbol-map.ts b/src/extension/crypto-trading/providers/ccxt/symbol-map.ts
deleted file mode 100644
index a3d414df..00000000
--- a/src/extension/crypto-trading/providers/ccxt/symbol-map.ts
+++ /dev/null
@@ -1,121 +0,0 @@
-/**
- * Symbol bidirectional mapping
- *
- * Internal symbol ("BTC/USD") <-> CCXT symbol ("BTC/USDT:USDT")
- *
- * Automatically discovers all available symbols from exchange.loadMarkets() results
- */
-
-interface MarketInfo {
- symbol: string;
- base: string;
- quote: string;
- type: string; // 'spot' | 'swap' | 'future' | 'option'
- settle?: string;
- active?: boolean;
- precision?: {
- price?: number;
- amount?: number;
- };
-}
-
-export class SymbolMapper {
- private internalToCcxt = new Map();
- private ccxtToInternal = new Map();
- private precisionMap = new Map();
-
- constructor(
- private defaultMarketType: 'spot' | 'swap',
- ) {}
-
- /**
- * Initialize mapping from ccxt exchange.markets
- *
- * Scans all exchange markets and builds a bidirectional mapping for every
- * base asset that has a USD/USDT-quoted spot or swap market.
- * For each base, picks the single best market using a priority scheme
- * determined by defaultMarketType.
- */
- init(markets: Record): void {
- // Group by base asset, keep only the best candidate per base
- const bestByBase = new Map();
-
- for (const [ccxtSymbol, market] of Object.entries(markets)) {
- if (market.active === false) continue;
-
- const isSwap = market.type === 'swap' || market.type === 'future';
- const isSpot = market.type === 'spot';
- const isUsdt = market.quote === 'USDT' || market.settle === 'USDT';
- const isUsd = market.quote === 'USD' || market.settle === 'USD';
-
- if (!isSwap && !isSpot) continue;
- if (!isUsdt && !isUsd) continue;
-
- let priority: number;
- if (this.defaultMarketType === 'swap') {
- if (isSwap && isUsdt) priority = 0;
- else if (isSwap && isUsd) priority = 1;
- else if (isSpot && isUsdt) priority = 2;
- else priority = 3;
- } else {
- if (isSpot && isUsdt) priority = 0;
- else if (isSpot && isUsd) priority = 1;
- else if (isSwap && isUsdt) priority = 2;
- else priority = 3;
- }
-
- const existing = bestByBase.get(market.base);
- if (!existing || priority < existing.priority) {
- bestByBase.set(market.base, { ccxtSymbol, priority });
- }
- }
-
- // Build bidirectional mappings
- for (const [base, best] of bestByBase) {
- const internalSymbol = `${base}/USD`;
- this.internalToCcxt.set(internalSymbol, best.ccxtSymbol);
- this.ccxtToInternal.set(best.ccxtSymbol, internalSymbol);
-
- const market = markets[best.ccxtSymbol];
- if (market?.precision) {
- this.precisionMap.set(internalSymbol, {
- price: market.precision.price ?? 2,
- amount: market.precision.amount ?? 8,
- });
- }
- }
- }
-
- /** Internal "BTC/USD" → CCXT "BTC/USDT:USDT" */
- toCcxt(internalSymbol: string): string {
- const ccxt = this.internalToCcxt.get(internalSymbol);
- if (!ccxt) {
- throw new Error(`No CCXT mapping for symbol: ${internalSymbol}`);
- }
- return ccxt;
- }
-
- /** CCXT "BTC/USDT:USDT" → Internal "BTC/USD" */
- toInternal(ccxtSymbol: string): string {
- const internal = this.ccxtToInternal.get(ccxtSymbol);
- if (!internal) {
- throw new Error(`No internal mapping for CCXT symbol: ${ccxtSymbol}`);
- }
- return internal;
- }
-
- /** Attempt conversion; returns null if no mapping exists */
- tryToInternal(ccxtSymbol: string): string | null {
- return this.ccxtToInternal.get(ccxtSymbol) ?? null;
- }
-
- /** Get symbol precision */
- getPrecision(internalSymbol: string): { price: number; amount: number } {
- return this.precisionMap.get(internalSymbol) ?? { price: 2, amount: 8 };
- }
-
- /** Get all mapped internal symbols */
- getMappedSymbols(): string[] {
- return [...this.internalToCcxt.keys()];
- }
-}
diff --git a/src/extension/crypto-trading/wallet-state-bridge.ts b/src/extension/crypto-trading/wallet-state-bridge.ts
deleted file mode 100644
index 309b5098..00000000
--- a/src/extension/crypto-trading/wallet-state-bridge.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-/**
- * Crypto Wallet State Bridge
- *
- * Provider-agnostic: ICryptoTradingEngine -> WalletState assembly
- * Used as the WalletConfig.getWalletState callback
- */
-
-import type { ICryptoTradingEngine } from './interfaces.js';
-import type { WalletState } from './wallet/types.js';
-
-export function createCryptoWalletStateBridge(engine: ICryptoTradingEngine) {
- return async (): Promise => {
- const [account, positions, orders] = await Promise.all([
- engine.getAccount(),
- engine.getPositions(),
- engine.getOrders(),
- ]);
-
- return {
- balance: account.balance,
- equity: account.equity,
- unrealizedPnL: account.unrealizedPnL,
- realizedPnL: account.realizedPnL,
- positions,
- pendingOrders: orders.filter(o => o.status === 'pending'),
- };
- };
-}
diff --git a/src/extension/crypto-trading/wallet/Wallet.spec.ts b/src/extension/crypto-trading/wallet/Wallet.spec.ts
deleted file mode 100644
index 64f67d89..00000000
--- a/src/extension/crypto-trading/wallet/Wallet.spec.ts
+++ /dev/null
@@ -1,519 +0,0 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { Wallet } from './Wallet.js';
-import type { WalletConfig } from './interfaces.js';
-import type { Operation, WalletState } from './types.js';
-
-// ==================== Mock Factory ====================
-
-function createMockConfig(overrides: Partial = {}): WalletConfig {
- return {
- executeOperation: vi.fn().mockResolvedValue({
- success: true,
- order: { id: 'ord-001', status: 'filled', filledPrice: 95000, filledQuantity: 0.1 },
- }),
- getWalletState: vi.fn().mockResolvedValue({
- balance: 10000, equity: 10000, unrealizedPnL: 0,
- realizedPnL: 0, positions: [], pendingOrders: [],
- } satisfies WalletState),
- onCommit: vi.fn(),
- ...overrides,
- };
-}
-
-function makeOp(overrides: Partial = {}): Operation {
- return {
- action: 'placeOrder',
- params: { symbol: 'BTC/USD', side: 'buy', type: 'market', size: 0.1 },
- ...overrides,
- };
-}
-
-// ==================== Tests ====================
-
-describe('Wallet', () => {
- let config: WalletConfig;
- let wallet: Wallet;
-
- beforeEach(() => {
- config = createMockConfig();
- wallet = new Wallet(config);
- });
-
- // ==================== add ====================
-
- describe('add', () => {
- it('stages an operation and returns AddResult', () => {
- const op = makeOp();
- const result = wallet.add(op);
-
- expect(result).toEqual({ staged: true, index: 0, operation: op });
- });
-
- it('increments index for each subsequent add', () => {
- const r1 = wallet.add(makeOp());
- const r2 = wallet.add(makeOp({ params: { symbol: 'ETH/USD', side: 'sell', type: 'market', size: 1 } }));
-
- expect(r1.index).toBe(0);
- expect(r2.index).toBe(1);
- });
- });
-
- // ==================== commit ====================
-
- describe('commit', () => {
- it('prepares a commit with hash and message', () => {
- wallet.add(makeOp());
- const result = wallet.commit('Buy BTC');
-
- expect(result.prepared).toBe(true);
- expect(result.hash).toHaveLength(8);
- expect(result.message).toBe('Buy BTC');
- expect(result.operationCount).toBe(1);
- });
-
- it('throws when staging area is empty', () => {
- expect(() => wallet.commit('empty')).toThrow('Nothing to commit: staging area is empty');
- });
- });
-
- // ==================== push ====================
-
- describe('push', () => {
- it('executes staged operations via config.executeOperation', async () => {
- const op = makeOp();
- wallet.add(op);
- wallet.commit('test');
- await wallet.push();
-
- expect(config.executeOperation).toHaveBeenCalledWith(op);
- });
-
- it('calls getWalletState after execution', async () => {
- wallet.add(makeOp());
- wallet.commit('test');
- await wallet.push();
-
- expect(config.getWalletState).toHaveBeenCalled();
- });
-
- it('records commit and updates head', async () => {
- wallet.add(makeOp());
- const { hash } = wallet.commit('test');
- await wallet.push();
-
- const status = wallet.status();
- expect(status.head).toBe(hash);
- expect(status.commitCount).toBe(1);
- });
-
- it('clears staging area after push', async () => {
- wallet.add(makeOp());
- wallet.commit('test');
- await wallet.push();
-
- const status = wallet.status();
- expect(status.staged).toEqual([]);
- expect(status.pendingMessage).toBeNull();
- });
-
- it('categorizes results into filled, pending, rejected', async () => {
- const execResults = [
- { success: true, order: { id: 'o1', status: 'filled', filledPrice: 95000, filledQuantity: 0.1 } },
- { success: true, order: { id: 'o2', status: 'pending' } },
- { success: false, error: 'Insufficient funds' },
- ];
- let callIdx = 0;
- config = createMockConfig({
- executeOperation: vi.fn().mockImplementation(() => Promise.resolve(execResults[callIdx++])),
- });
- wallet = new Wallet(config);
-
- wallet.add(makeOp());
- wallet.add(makeOp({ params: { symbol: 'BTC/USD', side: 'buy', type: 'limit', price: 90000, size: 0.1 } }));
- wallet.add(makeOp({ params: { symbol: 'ETH/USD', side: 'buy', type: 'market', size: 100 } }));
- wallet.commit('batch');
- const result = await wallet.push();
-
- expect(result.filled).toHaveLength(1);
- expect(result.pending).toHaveLength(1);
- expect(result.rejected).toHaveLength(1);
- });
-
- it('calls onCommit with exported state', async () => {
- wallet.add(makeOp());
- wallet.commit('test');
- await wallet.push();
-
- expect(config.onCommit).toHaveBeenCalledWith(
- expect.objectContaining({
- commits: expect.any(Array),
- head: expect.any(String),
- }),
- );
- });
-
- it('throws when staging area is empty', async () => {
- await expect(wallet.push()).rejects.toThrow('Nothing to push: staging area is empty');
- });
-
- it('throws when commit was not called first', async () => {
- wallet.add(makeOp());
- await expect(wallet.push()).rejects.toThrow('Nothing to push: please commit first');
- });
-
- it('handles executeOperation throwing an error', async () => {
- config = createMockConfig({
- executeOperation: vi.fn().mockRejectedValue(new Error('Network timeout')),
- });
- wallet = new Wallet(config);
-
- wallet.add(makeOp());
- wallet.commit('failing');
- const result = await wallet.push();
-
- expect(result.rejected).toHaveLength(1);
- expect(result.rejected[0].error).toBe('Network timeout');
- expect(result.rejected[0].status).toBe('rejected');
- });
- });
-
- // ==================== full cycle ====================
-
- describe('add -> commit -> push cycle', () => {
- it('full happy path', async () => {
- wallet.add(makeOp());
- wallet.commit('Buy 0.1 BTC');
- const result = await wallet.push();
-
- expect(result.operationCount).toBe(1);
- expect(result.filled).toHaveLength(1);
- expect(result.filled[0].orderId).toBe('ord-001');
- expect(result.filled[0].filledPrice).toBe(95000);
- });
-
- it('multiple operations in single push', async () => {
- wallet.add(makeOp());
- wallet.add(makeOp({ action: 'adjustLeverage', params: { symbol: 'BTC/USD', newLeverage: 10 } }));
- wallet.commit('batch operations');
- const result = await wallet.push();
-
- expect(result.operationCount).toBe(2);
- expect(config.executeOperation).toHaveBeenCalledTimes(2);
- });
-
- it('sequential pushes create chained commits', async () => {
- wallet.add(makeOp());
- wallet.commit('first');
- const r1 = await wallet.push();
-
- wallet.add(makeOp({ params: { symbol: 'ETH/USD', side: 'buy', type: 'market', size: 1 } }));
- wallet.commit('second');
- const r2 = await wallet.push();
-
- const c2 = wallet.show(r2.hash);
- expect(c2?.parentHash).toBe(r1.hash);
- expect(wallet.status().commitCount).toBe(2);
- });
- });
-
- // ==================== log ====================
-
- describe('log', () => {
- it('returns commits in reverse chronological order', async () => {
- wallet.add(makeOp());
- wallet.commit('first');
- await wallet.push();
-
- wallet.add(makeOp());
- wallet.commit('second');
- await wallet.push();
-
- const entries = wallet.log();
- expect(entries).toHaveLength(2);
- expect(entries[0].message).toBe('second');
- expect(entries[1].message).toBe('first');
- });
-
- it('respects limit parameter', async () => {
- for (let i = 0; i < 5; i++) {
- wallet.add(makeOp());
- wallet.commit(`commit ${i}`);
- await wallet.push();
- }
-
- expect(wallet.log({ limit: 3 })).toHaveLength(3);
- });
-
- it('filters by symbol', async () => {
- wallet.add(makeOp({ params: { symbol: 'BTC/USD', side: 'buy', type: 'market', size: 0.1 } }));
- wallet.commit('btc buy');
- await wallet.push();
-
- wallet.add(makeOp({ params: { symbol: 'ETH/USD', side: 'buy', type: 'market', size: 1 } }));
- wallet.commit('eth buy');
- await wallet.push();
-
- const entries = wallet.log({ symbol: 'ETH/USD' });
- expect(entries).toHaveLength(1);
- expect(entries[0].message).toBe('eth buy');
- });
-
- it('returns empty array when no commits', () => {
- expect(wallet.log()).toEqual([]);
- });
- });
-
- // ==================== show ====================
-
- describe('show', () => {
- it('returns commit by hash', async () => {
- wallet.add(makeOp());
- const { hash } = wallet.commit('test');
- await wallet.push();
-
- const commit = wallet.show(hash);
- expect(commit).not.toBeNull();
- expect(commit!.message).toBe('test');
- });
-
- it('returns null for unknown hash', () => {
- expect(wallet.show('deadbeef')).toBeNull();
- });
- });
-
- // ==================== status ====================
-
- describe('status', () => {
- it('shows initial empty state', () => {
- const s = wallet.status();
- expect(s.staged).toEqual([]);
- expect(s.pendingMessage).toBeNull();
- expect(s.head).toBeNull();
- expect(s.commitCount).toBe(0);
- });
-
- it('shows staged operations', () => {
- const op = makeOp();
- wallet.add(op);
-
- const s = wallet.status();
- expect(s.staged).toHaveLength(1);
- expect(s.staged[0]).toEqual(op);
- });
-
- it('shows pendingMessage after commit', () => {
- wallet.add(makeOp());
- wallet.commit('my message');
-
- expect(wallet.status().pendingMessage).toBe('my message');
- });
- });
-
- // ==================== sync ====================
-
- describe('sync', () => {
- it('creates a sync commit with order updates', async () => {
- const state: WalletState = {
- balance: 10000, equity: 10000, unrealizedPnL: 0,
- realizedPnL: 0, positions: [], pendingOrders: [],
- };
-
- const result = await wallet.sync(
- [{ orderId: 'ord-001', symbol: 'BTC/USD', previousStatus: 'pending', currentStatus: 'filled', filledPrice: 95000, filledSize: 0.1 }],
- state,
- );
-
- expect(result.updatedCount).toBe(1);
- expect(result.hash).toHaveLength(8);
- expect(wallet.status().head).toBe(result.hash);
- expect(config.onCommit).toHaveBeenCalled();
- });
-
- it('returns early when updates is empty', async () => {
- const state: WalletState = {
- balance: 10000, equity: 10000, unrealizedPnL: 0,
- realizedPnL: 0, positions: [], pendingOrders: [],
- };
-
- const result = await wallet.sync([], state);
-
- expect(result.updatedCount).toBe(0);
- expect(wallet.status().commitCount).toBe(0);
- });
- });
-
- // ==================== getPendingOrderIds ====================
-
- describe('getPendingOrderIds', () => {
- it('returns orders still in pending status', async () => {
- config = createMockConfig({
- executeOperation: vi.fn().mockResolvedValue({
- success: true,
- order: { id: 'ord-pending', status: 'pending' },
- }),
- });
- wallet = new Wallet(config);
-
- wallet.add(makeOp());
- wallet.commit('limit buy');
- await wallet.push();
-
- const pending = wallet.getPendingOrderIds();
- expect(pending).toEqual([{ orderId: 'ord-pending', symbol: 'BTC/USD' }]);
- });
-
- it('excludes orders updated to filled by sync', async () => {
- config = createMockConfig({
- executeOperation: vi.fn().mockResolvedValue({
- success: true,
- order: { id: 'ord-100', status: 'pending' },
- }),
- });
- wallet = new Wallet(config);
-
- wallet.add(makeOp());
- wallet.commit('limit buy');
- await wallet.push();
-
- // Sync fills the order
- const state: WalletState = {
- balance: 10000, equity: 10000, unrealizedPnL: 0,
- realizedPnL: 0, positions: [], pendingOrders: [],
- };
- await wallet.sync(
- [{ orderId: 'ord-100', symbol: 'BTC/USD', previousStatus: 'pending', currentStatus: 'filled', filledPrice: 95000, filledSize: 0.1 }],
- state,
- );
-
- expect(wallet.getPendingOrderIds()).toEqual([]);
- });
-
- it('returns empty array when no pending orders', async () => {
- wallet.add(makeOp());
- wallet.commit('market buy');
- await wallet.push();
-
- // Default mock returns filled status
- expect(wallet.getPendingOrderIds()).toEqual([]);
- });
- });
-
- // ==================== exportState / restore ====================
-
- describe('exportState / restore', () => {
- it('round-trips through export and restore', async () => {
- wallet.add(makeOp());
- wallet.commit('original');
- await wallet.push();
-
- const exported = wallet.exportState();
- const restored = Wallet.restore(exported, config);
-
- expect(restored.status().head).toBe(wallet.status().head);
- expect(restored.status().commitCount).toBe(1);
- expect(restored.log()).toHaveLength(1);
- });
- });
-
- // ==================== setCurrentRound ====================
-
- describe('setCurrentRound', () => {
- it('records round number in commits', async () => {
- wallet.setCurrentRound(3);
- wallet.add(makeOp());
- wallet.commit('round 3 buy');
- const result = await wallet.push();
-
- const commit = wallet.show(result.hash);
- expect(commit?.round).toBe(3);
- });
- });
-
- // ==================== simulatePriceChange ====================
-
- describe('simulatePriceChange', () => {
- it('returns no-change result when no positions', async () => {
- const result = await wallet.simulatePriceChange([
- { symbol: 'BTC/USD', change: '+10%' },
- ]);
-
- expect(result.success).toBe(true);
- expect(result.summary.totalPnLChange).toBe(0);
- expect(result.summary.worstCase).toBe('No positions to simulate.');
- });
-
- it('calculates PnL for long position with relative change', async () => {
- config = createMockConfig({
- getWalletState: vi.fn().mockResolvedValue({
- balance: 10000, equity: 10500, unrealizedPnL: 500,
- realizedPnL: 0,
- positions: [{
- symbol: 'BTC/USD', side: 'long', size: 0.1, entryPrice: 90000,
- leverage: 1, margin: 9000, liquidationPrice: 0,
- markPrice: 95000, unrealizedPnL: 500, positionValue: 9500,
- }],
- pendingOrders: [],
- } satisfies WalletState),
- });
- wallet = new Wallet(config);
-
- const result = await wallet.simulatePriceChange([
- { symbol: 'BTC/USD', change: '+10%' },
- ]);
-
- expect(result.success).toBe(true);
- // New price: 95000 * 1.1 = 104500
- // New PnL: (104500 - 90000) * 0.1 = 1450
- expect(result.simulatedState.positions[0].simulatedPrice).toBeCloseTo(104500);
- expect(result.simulatedState.positions[0].unrealizedPnL).toBeCloseTo(1450);
- expect(result.summary.totalPnLChange).toBeCloseTo(950); // 1450 - 500
- });
-
- it('calculates PnL for absolute price change', async () => {
- config = createMockConfig({
- getWalletState: vi.fn().mockResolvedValue({
- balance: 10000, equity: 10500, unrealizedPnL: 500,
- realizedPnL: 0,
- positions: [{
- symbol: 'BTC/USD', side: 'long', size: 0.1, entryPrice: 90000,
- leverage: 1, margin: 9000, liquidationPrice: 0,
- markPrice: 95000, unrealizedPnL: 500, positionValue: 9500,
- }],
- pendingOrders: [],
- } satisfies WalletState),
- });
- wallet = new Wallet(config);
-
- const result = await wallet.simulatePriceChange([
- { symbol: 'BTC/USD', change: '@100000' },
- ]);
-
- expect(result.success).toBe(true);
- expect(result.simulatedState.positions[0].simulatedPrice).toBe(100000);
- // New PnL: (100000 - 90000) * 0.1 = 1000
- expect(result.simulatedState.positions[0].unrealizedPnL).toBeCloseTo(1000);
- });
-
- it('returns error for invalid change format', async () => {
- config = createMockConfig({
- getWalletState: vi.fn().mockResolvedValue({
- balance: 10000, equity: 10000, unrealizedPnL: 0, realizedPnL: 0,
- positions: [{
- symbol: 'BTC/USD', side: 'long', size: 0.1, entryPrice: 90000,
- leverage: 1, margin: 9000, liquidationPrice: 0,
- markPrice: 95000, unrealizedPnL: 500, positionValue: 9500,
- }],
- pendingOrders: [],
- } satisfies WalletState),
- });
- wallet = new Wallet(config);
-
- const result = await wallet.simulatePriceChange([
- { symbol: 'BTC/USD', change: 'invalid' },
- ]);
-
- expect(result.success).toBe(false);
- expect(result.error).toContain('Invalid change format');
- });
- });
-});
diff --git a/src/extension/crypto-trading/wallet/Wallet.ts b/src/extension/crypto-trading/wallet/Wallet.ts
deleted file mode 100644
index 1020a21c..00000000
--- a/src/extension/crypto-trading/wallet/Wallet.ts
+++ /dev/null
@@ -1,702 +0,0 @@
-/**
- * Wallet implementation
- *
- * Git-like wallet state management, tracking trading operation history
- */
-
-import { createHash } from 'crypto';
-import type { IWallet, WalletConfig } from './interfaces';
-import type {
- CommitHash,
- Operation,
- OperationResult,
- AddResult,
- CommitPrepareResult,
- PushResult,
- WalletStatus,
- WalletCommit,
- WalletState,
- CommitLogEntry,
- WalletExportState,
- OperationSummary,
- PriceChangeInput,
- SimulatePriceChangeResult,
- OrderStatusUpdate,
- SyncResult,
-} from './types';
-
-/**
- * Generate Commit Hash
- *
- * Uses SHA-256 to hash the content, taking the first 8 characters
- */
-function generateCommitHash(content: object): CommitHash {
- const hash = createHash('sha256')
- .update(JSON.stringify(content))
- .digest('hex');
- return hash.slice(0, 8);
-}
-
-/**
- * Wallet - Git-like wallet state management
- *
- * Usage:
- * 1. add() to stage operations
- * 2. commit() to add a message
- * 3. push() to execute and record
- */
-export class Wallet implements IWallet {
- // Staging area
- private stagingArea: Operation[] = [];
- private pendingMessage: string | null = null;
- private pendingHash: CommitHash | null = null;
-
- // History
- private commits: WalletCommit[] = [];
- private head: CommitHash | null = null;
-
- // Current round
- private currentRound: number | undefined = undefined;
-
- // Configuration
- private readonly config: WalletConfig;
-
- constructor(config: WalletConfig) {
- this.config = config;
- }
-
- // ==================== Git three-stage ====================
-
- add(operation: Operation): AddResult {
- this.stagingArea.push(operation);
- return {
- staged: true,
- index: this.stagingArea.length - 1,
- operation,
- };
- }
-
- commit(message: string): CommitPrepareResult {
- if (this.stagingArea.length === 0) {
- throw new Error('Nothing to commit: staging area is empty');
- }
-
- // Pre-generate hash (based on message + operations + timestamp)
- const timestamp = new Date().toISOString();
- this.pendingHash = generateCommitHash({
- message,
- operations: this.stagingArea,
- timestamp,
- parentHash: this.head,
- });
- this.pendingMessage = message;
-
- return {
- prepared: true,
- hash: this.pendingHash,
- message,
- operationCount: this.stagingArea.length,
- };
- }
-
- async push(): Promise {
- if (this.stagingArea.length === 0) {
- throw new Error('Nothing to push: staging area is empty');
- }
-
- if (this.pendingMessage === null || this.pendingHash === null) {
- throw new Error('Nothing to push: please commit first');
- }
-
- const operations = [...this.stagingArea];
- const message = this.pendingMessage;
- const hash = this.pendingHash;
-
- // Execute all operations
- const results: OperationResult[] = [];
- for (const op of operations) {
- try {
- const raw = await this.config.executeOperation(op);
- const result = this.parseOperationResult(op, raw);
- results.push(result);
- } catch (error) {
- results.push({
- action: op.action,
- success: false,
- status: 'rejected',
- error: error instanceof Error ? error.message : String(error),
- });
- }
- }
-
- // Get current wallet state
- const stateAfter = await this.config.getWalletState();
-
- // Create commit
- const commit: WalletCommit = {
- hash,
- parentHash: this.head,
- message,
- operations,
- results,
- stateAfter,
- timestamp: new Date().toISOString(),
- round: this.currentRound,
- };
-
- // Update history
- this.commits.push(commit);
- this.head = hash;
-
- // Persist
- await this.config.onCommit?.(this.exportState());
-
- // Clear staging area
- this.stagingArea = [];
- this.pendingMessage = null;
- this.pendingHash = null;
-
- // Categorize results
- const filled = results.filter((r) => r.status === 'filled');
- const pending = results.filter((r) => r.status === 'pending');
- const rejected = results.filter(
- (r) => r.status === 'rejected' || !r.success,
- );
-
- return {
- hash,
- message,
- operationCount: operations.length,
- filled,
- pending,
- rejected,
- };
- }
-
- // ==================== Query ====================
-
- /**
- * View commit history (similar to git log --stat)
- *
- * @param options.limit - Number of commits to return (default 10)
- * @param options.symbol - Filter commits by symbol (similar to git log -- file)
- */
- log(options: { limit?: number; symbol?: string } = {}): CommitLogEntry[] {
- const { limit = 10, symbol } = options;
-
- // From newest to oldest
- let commits = this.commits.slice().reverse();
-
- // If a symbol is specified, only keep commits containing that symbol
- if (symbol) {
- commits = commits.filter((commit) =>
- commit.operations.some((op) => op.params.symbol === symbol),
- );
- }
-
- // Limit count
- commits = commits.slice(0, limit);
-
- return commits.map((commit) => ({
- hash: commit.hash,
- parentHash: commit.parentHash,
- message: commit.message,
- timestamp: commit.timestamp,
- round: commit.round,
- operations: this.buildOperationSummaries(commit, symbol),
- }));
- }
-
- /**
- * Build operation summaries (similar to file changes in git log --stat)
- */
- private buildOperationSummaries(
- commit: WalletCommit,
- filterSymbol?: string,
- ): OperationSummary[] {
- const summaries: OperationSummary[] = [];
-
- for (let i = 0; i < commit.operations.length; i++) {
- const op = commit.operations[i];
- const result = commit.results[i];
- const symbol = (op.params.symbol as string) || 'unknown';
-
- // If symbol filter is specified, skip non-matching entries
- if (filterSymbol && symbol !== filterSymbol) {
- continue;
- }
-
- const change = this.formatOperationChange(op, result);
- summaries.push({
- symbol,
- action: op.action,
- change,
- status: result?.status || 'rejected',
- });
- }
-
- return summaries;
- }
-
- /**
- * Format operation change description
- */
- private formatOperationChange(op: Operation, result?: OperationResult): string {
- const { action, params } = op;
-
- switch (action) {
- case 'placeOrder': {
- const side = params.side as string;
- const usdSize = params.usd_size as number | undefined;
- const size = params.size as number | undefined;
- const sizeStr = usdSize ? `$${usdSize}` : `${size}`;
- const direction = side === 'buy' ? 'long' : 'short';
-
- if (result?.status === 'filled') {
- const price = result.filledPrice ? ` @${result.filledPrice}` : '';
- return `${direction} +${sizeStr}${price}`;
- }
- return `${direction} +${sizeStr} (${result?.status || 'unknown'})`;
- }
-
- case 'closePosition': {
- const size = params.size as number | undefined;
- if (result?.status === 'filled') {
- const price = result.filledPrice ? ` @${result.filledPrice}` : '';
- const sizeStr = size ? ` (partial: ${size})` : '';
- return `closed${sizeStr}${price}`;
- }
- return `close (${result?.status || 'unknown'})`;
- }
-
- case 'cancelOrder': {
- return `cancelled order ${params.orderId}`;
- }
-
- case 'adjustLeverage': {
- const newLev = params.newLeverage as number;
- return `leverage → ${newLev}x`;
- }
-
- case 'syncOrders': {
- const status = result?.status || 'unknown';
- const price = result?.filledPrice ? ` @${result.filledPrice}` : '';
- return `synced → ${status}${price}`;
- }
-
- default:
- return `${action}`;
- }
- }
-
- show(hash: CommitHash): WalletCommit | null {
- return this.commits.find((c) => c.hash === hash) ?? null;
- }
-
- status(): WalletStatus {
- return {
- staged: [...this.stagingArea],
- pendingMessage: this.pendingMessage,
- head: this.head,
- commitCount: this.commits.length,
- };
- }
-
- // ==================== Serialization ====================
-
- exportState(): WalletExportState {
- return {
- commits: [...this.commits],
- head: this.head,
- };
- }
-
- /**
- * Restore Wallet from exported state
- */
- static restore(state: WalletExportState, config: WalletConfig): Wallet {
- const wallet = new Wallet(config);
- wallet.commits = [...state.commits];
- wallet.head = state.head;
- return wallet;
- }
-
- setCurrentRound(round: number): void {
- this.currentRound = round;
- }
-
- // ==================== Sync ====================
-
- /**
- * Fetch latest order statuses from exchange and record changes (similar to git pull)
- *
- * Bypasses the staging area to directly create a sync commit
- */
- async sync(updates: OrderStatusUpdate[], currentState: WalletState): Promise {
- if (updates.length === 0) {
- return { hash: this.head ?? '', updatedCount: 0, updates: [] };
- }
-
- const hash = generateCommitHash({
- updates,
- timestamp: new Date().toISOString(),
- parentHash: this.head,
- });
-
- const commit: WalletCommit = {
- hash,
- parentHash: this.head,
- message: `[sync] ${updates.length} order(s) updated`,
- operations: [{ action: 'syncOrders', params: { orderIds: updates.map(u => u.orderId) } }],
- results: updates.map(u => ({
- action: 'syncOrders' as const,
- success: true,
- orderId: u.orderId,
- status: u.currentStatus,
- filledPrice: u.filledPrice,
- filledSize: u.filledSize,
- })),
- stateAfter: currentState,
- timestamp: new Date().toISOString(),
- round: this.currentRound,
- };
-
- this.commits.push(commit);
- this.head = hash;
-
- // Persist
- await this.config.onCommit?.(this.exportState());
-
- return { hash, updatedCount: updates.length, updates };
- }
-
- /**
- * Get all order IDs that are still in pending status
- *
- * Scans commit history from newest to oldest, finding pending orders not updated by subsequent syncs
- */
- getPendingOrderIds(): Array<{ orderId: string; symbol: string }> {
- // Scan from newest to oldest, recording the latest known status of each orderId
- const orderStatus = new Map();
-
- for (let i = this.commits.length - 1; i >= 0; i--) {
- for (const result of this.commits[i].results) {
- if (result.orderId && !orderStatus.has(result.orderId)) {
- orderStatus.set(result.orderId, result.status);
- }
- }
- }
-
- // Find orders that are still pending
- const pending: Array<{ orderId: string; symbol: string }> = [];
- const seen = new Set();
-
- for (const commit of this.commits) {
- for (let j = 0; j < commit.results.length; j++) {
- const result = commit.results[j];
- if (
- result.orderId &&
- !seen.has(result.orderId) &&
- orderStatus.get(result.orderId) === 'pending'
- ) {
- const symbol = (commit.operations[j]?.params?.symbol as string) ?? 'unknown';
- pending.push({ orderId: result.orderId, symbol });
- seen.add(result.orderId);
- }
- }
- }
-
- return pending;
- }
-
- // ==================== Simulation ====================
-
- /**
- * Simulate the impact of price changes on the portfolio (Dry Run)
- */
- async simulatePriceChange(
- priceChanges: PriceChangeInput[],
- ): Promise {
- // Get current state
- const state = await this.config.getWalletState();
- const { positions, equity, unrealizedPnL, balance } = state;
-
- // Calculate current totalPnL
- const currentTotalPnL =
- balance > 0 ? ((equity - balance) / balance) * 100 : 0;
-
- if (positions.length === 0) {
- return {
- success: true,
- currentState: {
- equity,
- unrealizedPnL,
- totalPnL: currentTotalPnL,
- positions: [],
- },
- simulatedState: {
- equity,
- unrealizedPnL,
- totalPnL: currentTotalPnL,
- positions: [],
- },
- summary: {
- totalPnLChange: 0,
- equityChange: 0,
- equityChangePercent: '0.0%',
- worstCase: 'No positions to simulate.',
- },
- };
- }
-
- // Parse price changes
- const priceMap = new Map(); // symbol -> new price
-
- for (const { symbol, change } of priceChanges) {
- const parsed = this.parsePriceChange(change);
- if (!parsed.success) {
- return {
- success: false,
- error: `Invalid change format for ${symbol}: "${change}". Use "@88000" for absolute or "+10%" / "-5%" for relative.`,
- currentState: {
- equity,
- unrealizedPnL,
- totalPnL: currentTotalPnL,
- positions: [],
- },
- simulatedState: {
- equity,
- unrealizedPnL,
- totalPnL: currentTotalPnL,
- positions: [],
- },
- summary: {
- totalPnLChange: 0,
- equityChange: 0,
- equityChangePercent: '0.0%',
- worstCase: '',
- },
- };
- }
-
- if (symbol === 'all') {
- // Apply to all positions
- for (const pos of positions) {
- const newPrice = this.applyPriceChange(
- pos.markPrice,
- parsed.type,
- parsed.value,
- );
- priceMap.set(pos.symbol, newPrice);
- }
- } else {
- // Apply to the specified trading pair
- const pos = positions.find((p) => p.symbol === symbol);
- if (pos) {
- const newPrice = this.applyPriceChange(
- pos.markPrice,
- parsed.type,
- parsed.value,
- );
- priceMap.set(symbol, newPrice);
- }
- }
- }
-
- // Calculate current state
- const currentPositions = positions.map((pos) => ({
- symbol: pos.symbol,
- side: pos.side,
- size: pos.size,
- entryPrice: pos.entryPrice,
- currentPrice: pos.markPrice,
- unrealizedPnL: pos.unrealizedPnL,
- positionValue: pos.positionValue,
- }));
-
- // Calculate simulated state
- let simulatedUnrealizedPnL = 0;
- const simulatedPositions = positions.map((pos) => {
- const simulatedPrice = priceMap.get(pos.symbol) ?? pos.markPrice;
- const priceChange = simulatedPrice - pos.markPrice;
- const priceChangePercent =
- pos.markPrice > 0 ? (priceChange / pos.markPrice) * 100 : 0;
-
- // Calculate new unrealized PnL
- // Long: (newPrice - entryPrice) * size
- // Short: (entryPrice - newPrice) * size
- const newUnrealizedPnL =
- pos.side === 'long'
- ? (simulatedPrice - pos.entryPrice) * pos.size
- : (pos.entryPrice - simulatedPrice) * pos.size;
-
- const pnlChange = newUnrealizedPnL - pos.unrealizedPnL;
- simulatedUnrealizedPnL += newUnrealizedPnL;
-
- return {
- symbol: pos.symbol,
- side: pos.side,
- size: pos.size,
- entryPrice: pos.entryPrice,
- simulatedPrice,
- unrealizedPnL: newUnrealizedPnL,
- positionValue: simulatedPrice * pos.size,
- pnlChange,
- priceChangePercent: `${priceChangePercent >= 0 ? '+' : ''}${priceChangePercent.toFixed(2)}%`,
- };
- });
-
- // Calculate simulated account state
- const pnlDiff = simulatedUnrealizedPnL - unrealizedPnL;
- const simulatedEquity = equity + pnlDiff;
- const simulatedTotalPnL =
- balance > 0 ? ((simulatedEquity - balance) / balance) * 100 : 0;
-
- const equityChangePercent = equity > 0 ? (pnlDiff / equity) * 100 : 0;
-
- // Find the position with the largest loss
- const worstPosition = simulatedPositions.reduce(
- (worst, pos) => (pos.pnlChange < worst.pnlChange ? pos : worst),
- simulatedPositions[0],
- );
-
- const worstCase =
- worstPosition.pnlChange < 0
- ? `${worstPosition.symbol} would lose $${Math.abs(worstPosition.pnlChange).toFixed(2)} (${worstPosition.priceChangePercent})`
- : 'All positions would profit or break even.';
-
- return {
- success: true,
- currentState: {
- equity,
- unrealizedPnL,
- totalPnL: currentTotalPnL,
- positions: currentPositions,
- },
- simulatedState: {
- equity: simulatedEquity,
- unrealizedPnL: simulatedUnrealizedPnL,
- totalPnL: simulatedTotalPnL,
- positions: simulatedPositions,
- },
- summary: {
- totalPnLChange: pnlDiff,
- equityChange: pnlDiff,
- equityChangePercent: `${equityChangePercent >= 0 ? '+' : ''}${equityChangePercent.toFixed(2)}%`,
- worstCase,
- },
- };
- }
-
- /**
- * Parse price change string
- */
- private parsePriceChange(
- change: string,
- ):
- | { success: true; type: 'absolute' | 'relative'; value: number }
- | { success: false } {
- const trimmed = change.trim();
-
- // Absolute value: @88000
- if (trimmed.startsWith('@')) {
- const value = parseFloat(trimmed.slice(1));
- if (isNaN(value) || value <= 0) {
- return { success: false };
- }
- return { success: true, type: 'absolute', value };
- }
-
- // Relative value: +10% or -5%
- if (trimmed.endsWith('%')) {
- const valueStr = trimmed.slice(0, -1);
- const value = parseFloat(valueStr);
- if (isNaN(value)) {
- return { success: false };
- }
- return { success: true, type: 'relative', value };
- }
-
- return { success: false };
- }
-
- /**
- * Apply price change
- */
- private applyPriceChange(
- currentPrice: number,
- type: 'absolute' | 'relative',
- value: number,
- ): number {
- if (type === 'absolute') {
- return value;
- } else {
- // relative: +10% means 1.1x, -5% means 0.95x
- return currentPrice * (1 + value / 100);
- }
- }
-
- // ==================== Internal methods ====================
-
- /**
- * Parse operation execution result
- *
- * Converts the raw result returned by the engine into a standardized OperationResult
- */
- private parseOperationResult(op: Operation, raw: unknown): OperationResult {
- // raw is the result returned by TradingEngine, format similar to:
- // { success: true, order: { id, status, filledPrice, ... } }
- // or { success: false, error: '...' }
-
- const rawObj = raw as Record;
-
- if (!rawObj || typeof rawObj !== 'object') {
- return {
- action: op.action,
- success: false,
- status: 'rejected',
- error: 'Invalid response from trading engine',
- raw,
- };
- }
-
- const success = rawObj.success === true;
- const order = rawObj.order as Record | undefined;
-
- if (!success) {
- return {
- action: op.action,
- success: false,
- status: 'rejected',
- error: (rawObj.error as string) ?? 'Unknown error',
- raw,
- };
- }
-
- if (!order) {
- // Some operations may not have an order (e.g. adjustLeverage)
- return {
- action: op.action,
- success: true,
- status: 'filled',
- raw,
- };
- }
-
- const status = order.status as string;
- const isFilled = status === 'filled';
- const isPending = status === 'pending';
-
- return {
- action: op.action,
- success: true,
- orderId: order.id as string | undefined,
- status: isFilled ? 'filled' : isPending ? 'pending' : 'rejected',
- filledPrice: isFilled ? (order.filledPrice as number) : undefined,
- filledSize: isFilled
- ? ((order.filledQuantity ?? order.size) as number)
- : undefined,
- raw,
- };
- }
-}
diff --git a/src/extension/crypto-trading/wallet/adapter.ts b/src/extension/crypto-trading/wallet/adapter.ts
deleted file mode 100644
index 7e092dbf..00000000
--- a/src/extension/crypto-trading/wallet/adapter.ts
+++ /dev/null
@@ -1,191 +0,0 @@
-import { tool } from 'ai';
-import { z } from 'zod';
-import type { IWallet } from './interfaces';
-
-/**
- * Create crypto wallet AI tools (decision management)
- *
- * Git-like operations for tracking and reviewing crypto trading decisions:
- * - cryptoWalletCommit/cryptoWalletPush: Record decisions with explanations
- * - cryptoWalletLog/cryptoWalletShow/cryptoWalletStatus: Review decision history
- * - cryptoSimulatePriceChange: Dry-run impact analysis
- */
-export function createCryptoWalletToolsImpl(wallet: IWallet) {
- return {
- cryptoWalletCommit: tool({
- description: `
-Commit staged crypto trading operations with a message (like "git commit -m").
-
-After staging operations with cryptoPlaceOrder/cryptoClosePosition/etc., use this to:
-1. Add a commit message explaining WHY you're making these trades
-2. Prepare the operations for execution
-
-This does NOT execute the trades yet - call cryptoWalletPush after this.
-
-Example workflow:
-1. cryptoPlaceOrder({ symbol: "BTC/USD", side: "buy", ... }) → staged
-2. cryptoWalletCommit({ message: "Going long BTC due to bullish RSI crossover" })
-3. cryptoWalletPush() → executes and records
- `.trim(),
- inputSchema: z.object({
- message: z
- .string()
- .describe(
- 'Commit message explaining your trading decision (will be recorded for future reference)',
- ),
- }),
- execute: ({ message }) => {
- return wallet.commit(message);
- },
- }),
-
- cryptoWalletPush: tool({
- description: `
-Execute all committed crypto trading operations (like "git push").
-
-After staging operations and committing them, use this to:
-1. Execute all staged operations against the crypto trading engine
-2. Record the commit with results to wallet history
-
-Returns execution results for each operation (filled/pending/rejected).
-
-IMPORTANT: You must call cryptoWalletCommit first before pushing.
- `.trim(),
- inputSchema: z.object({}),
- execute: async () => {
- return await wallet.push();
- },
- }),
-
- cryptoWalletLog: tool({
- description: `
-View your crypto trading decision history (like "git log --stat").
-
-IMPORTANT: Check this BEFORE making new trading decisions to:
-- Review what you planned in recent commits
-- Avoid contradicting your own strategy
-- Maintain consistency across rounds
-- Recall stop-loss/take-profit levels you set
-
-Returns recent trading commits in reverse chronological order (newest first).
-Each commit includes:
-- hash: Unique commit identifier
-- message: Your explanation for the trades (WHY you made them)
-- operations: Summary of each operation (symbol, action, change, status)
- Example: { symbol: "BTC/USD", action: "placeOrder", change: "long +$1000 @95000", status: "filled" }
-- timestamp: When the commit was made
-- round: Which backtest round
-
-Use symbol parameter to filter commits for a specific trading pair (like "git log -- file").
-Use cryptoWalletShow(hash) for full details of a specific commit.
- `.trim(),
- inputSchema: z.object({
- limit: z
- .number()
- .int()
- .positive()
- .optional()
- .describe('Number of recent commits to return (default: 10)'),
- symbol: z
- .string()
- .optional()
- .describe('Filter commits by symbol (e.g., "BTC/USD"). Only shows commits that affected this symbol.'),
- }),
- execute: ({ limit, symbol }) => {
- return wallet.log({ limit, symbol });
- },
- }),
-
- cryptoWalletShow: tool({
- description: `
-View details of a specific crypto wallet commit (like "git show ").
-
-Returns full commit information including:
-- All operations that were executed
-- Results of each operation (filled price, size, errors)
-- Wallet state after the commit (positions, balance)
-
-Use this to inspect what happened in a specific trading commit.
- `.trim(),
- inputSchema: z.object({
- hash: z.string().describe('Commit hash to inspect (8 characters)'),
- }),
- execute: ({ hash }) => {
- const commit = wallet.show(hash);
- if (!commit) {
- return { error: `Commit ${hash} not found` };
- }
- return commit;
- },
- }),
-
- cryptoWalletStatus: tool({
- description: `
-View current crypto wallet staging area status (like "git status").
-
-Returns:
-- staged: List of operations waiting to be committed/pushed
-- pendingMessage: Commit message if already committed but not pushed
-- head: Hash of the latest commit
-- commitCount: Total number of commits in history
-
-Use this to check if you have pending operations before making more trades.
- `.trim(),
- inputSchema: z.object({}),
- execute: () => {
- return wallet.status();
- },
- }),
-
- cryptoSimulatePriceChange: tool({
- description: `
-Simulate price changes to see crypto portfolio impact BEFORE making trading decisions (dry run).
-
-Use this tool to:
-- See how much you would lose if price drops to your stop-loss level
-- Understand the impact of market movements on your portfolio
-- Make informed decisions about position sizing and risk management
-
-Price change syntax:
-- Absolute: "@88000" means price becomes $88,000
-- Relative: "+10%" means price increases by 10%, "-5%" means price decreases by 5%
-
-You can simulate changes for:
-- A specific symbol: { symbol: "BTC/USD", change: "@88000" }
-- All positions: { symbol: "all", change: "-10%" }
-
-Example usage:
-1. Before setting a stop-loss at $88k: cryptoSimulatePriceChange([{ symbol: "BTC/USD", change: "@88000" }])
-2. Stress test a 10% market crash: cryptoSimulatePriceChange([{ symbol: "all", change: "-10%" }])
-
-Returns:
-- currentState: Your actual portfolio state
-- simulatedState: What your portfolio would look like after the price change
-- summary: Total PnL change, equity change, and worst-case position
-
-IMPORTANT: This is READ-ONLY - it does NOT modify your actual positions.
- `.trim(),
- inputSchema: z.object({
- priceChanges: z
- .array(
- z.object({
- symbol: z
- .string()
- .describe(
- 'Trading pair (e.g., "BTC/USD") or "all" for all positions',
- ),
- change: z
- .string()
- .describe(
- 'Price change: "@88000" for absolute, "+10%" or "-5%" for relative',
- ),
- }),
- )
- .describe('Array of price changes to simulate'),
- }),
- execute: async ({ priceChanges }) => {
- return await wallet.simulatePriceChange(priceChanges);
- },
- }),
- };
-}
diff --git a/src/extension/crypto-trading/wallet/interfaces.ts b/src/extension/crypto-trading/wallet/interfaces.ts
deleted file mode 100644
index 815ec5ea..00000000
--- a/src/extension/crypto-trading/wallet/interfaces.ts
+++ /dev/null
@@ -1,153 +0,0 @@
-/**
- * Wallet interface definitions
- *
- * Git-like wallet state management interfaces
- */
-
-import type {
- CommitHash,
- Operation,
- AddResult,
- CommitPrepareResult,
- PushResult,
- WalletStatus,
- WalletCommit,
- CommitLogEntry,
- WalletExportState,
- PriceChangeInput,
- SimulatePriceChangeResult,
- OrderStatusUpdate,
- SyncResult,
- WalletState,
-} from './types';
-
-/**
- * IWallet - Wallet interface
- *
- * Provides Git three-stage operations:
- * - add: Stage an operation
- * - commit: Add a commit message
- * - push: Execute and record
- *
- * And query capabilities:
- * - log: View commit history
- * - show: View details of a specific commit
- * - status: View current state
- */
-export interface IWallet {
- // ==================== Git three-stage ====================
-
- /**
- * git add - Stage an operation
- *
- * @param operation The operation to stage
- * @returns Staging result
- */
- add(operation: Operation): AddResult;
-
- /**
- * git commit -m - Add a commit message for staged operations
- *
- * @param message Commit message
- * @returns Prepared commit info
- */
- commit(message: string): CommitPrepareResult;
-
- /**
- * git push - Execute staged operations and record the commit
- *
- * @returns Execution result
- */
- push(): Promise;
-
- // ==================== Query ====================
-
- /**
- * git log - View commit history (similar to git log --stat)
- *
- * @param options.limit Maximum number of results (default 10)
- * @param options.symbol Filter commits by symbol (similar to git log -- file)
- * @returns Commit history (newest first), with operation summaries
- */
- log(options?: { limit?: number; symbol?: string }): CommitLogEntry[];
-
- /**
- * git show - View detailed information of a specific commit
- *
- * @param hash Commit hash
- * @returns Commit details, or null if not found
- */
- show(hash: CommitHash): WalletCommit | null;
-
- /**
- * git status - View current state
- *
- * @returns Current staging area and HEAD info
- */
- status(): WalletStatus;
-
- // ==================== Sync ====================
-
- /**
- * git pull - Fetch latest order statuses from exchange and record changes
- *
- * Bypasses the staging area to directly create a sync commit, recording order status updates
- *
- * @param updates List of order status changes
- * @param currentState Current wallet state snapshot
- * @returns Sync result
- */
- sync(updates: OrderStatusUpdate[], currentState: WalletState): Promise;
-
- /**
- * Get all order IDs that are still in pending status
- *
- * Scans commit history to find all orders with status='pending' that haven't been updated by subsequent syncs
- */
- getPendingOrderIds(): Array<{ orderId: string; symbol: string }>;
-
- // ==================== Serialization ====================
-
- /**
- * Export state (for saving to snapshot)
- */
- exportState(): WalletExportState;
-
- /**
- * Set the current round (used for commit metadata)
- */
- setCurrentRound(round: number): void;
-
- // ==================== Simulation ====================
-
- /**
- * Simulate the impact of price changes on the portfolio (Dry Run)
- *
- * Allows AI to simulate "what if the price becomes X" scenarios before making decisions
- * Does not actually modify any state; only returns simulation results
- *
- * @param priceChanges Array of price changes
- * - symbol: Trading pair (e.g. "BTC/USD") or "all"
- * - change: Price change, supports:
- * - Absolute: "@88000" means price becomes 88000
- * - Relative: "+10%" or "-5%" for percentage change
- * @returns Simulation result
- */
- simulatePriceChange(
- priceChanges: PriceChangeInput[],
- ): Promise;
-}
-
-/**
- * Wallet constructor parameters
- */
-export interface WalletConfig {
- /** Callback function for executing operations */
- executeOperation: (operation: Operation) => Promise;
-
- /** Callback function for getting the current wallet state */
- getWalletState: () => Promise;
-
- /** Called after each commit is persisted (push/sync), used for persistence */
- onCommit?: (state: import('./types').WalletExportState) => void | Promise;
-}
diff --git a/src/extension/crypto-trading/wallet/types.ts b/src/extension/crypto-trading/wallet/types.ts
deleted file mode 100644
index 31614260..00000000
--- a/src/extension/crypto-trading/wallet/types.ts
+++ /dev/null
@@ -1,222 +0,0 @@
-/**
- * Wallet type definitions
- *
- * Git-like wallet state management for tracking trading operation history
- */
-
-import type { CryptoPosition, CryptoOrder } from '../interfaces';
-
-// ==================== Commit Hash ====================
-
-/** Commit Hash - 8-character short hash, used for indexing */
-export type CommitHash = string;
-
-// ==================== Operation ====================
-
-/** Supported operation types */
-export type OperationAction =
- | 'placeOrder'
- | 'closePosition'
- | 'cancelOrder'
- | 'adjustLeverage'
- | 'syncOrders';
-
-/** Staged operation */
-export interface Operation {
- action: OperationAction;
- params: Record;
-}
-
-// ==================== Operation Result ====================
-
-/** Operation execution status */
-export type OperationStatus = 'filled' | 'pending' | 'rejected' | 'cancelled';
-
-/** Operation execution result */
-export interface OperationResult {
- action: OperationAction;
- success: boolean;
- orderId?: string;
- status: OperationStatus;
- // Fill information (when filled)
- filledPrice?: number;
- filledSize?: number;
- // Error information (when rejected)
- error?: string;
- // Raw response (preserves complete information)
- raw?: unknown;
-}
-
-// ==================== Wallet State ====================
-
-/** Wallet state snapshot */
-export interface WalletState {
- balance: number;
- equity: number;
- unrealizedPnL: number;
- realizedPnL: number;
- positions: CryptoPosition[];
- pendingOrders: CryptoOrder[];
-}
-
-// ==================== Wallet Commit ====================
-
-/** Wallet Commit - Complete record of a single commit */
-export interface WalletCommit {
- // Identifiers
- hash: CommitHash;
- parentHash: CommitHash | null;
-
- // Content
- message: string;
- operations: Operation[];
- results: OperationResult[];
-
- // State snapshot (wallet state after commit)
- stateAfter: WalletState;
-
- // Metadata
- timestamp: string; // ISO timestamp
- round?: number; // Associated round (optional)
-}
-
-// ==================== API Results ====================
-
-/** add() return value */
-export interface AddResult {
- staged: true;
- index: number;
- operation: Operation;
-}
-
-/** commit() return value */
-export interface CommitPrepareResult {
- prepared: true;
- hash: CommitHash; // Pre-generated hash
- message: string;
- operationCount: number;
-}
-
-/** push() return value */
-export interface PushResult {
- hash: CommitHash;
- message: string;
- operationCount: number;
- filled: OperationResult[];
- pending: OperationResult[];
- rejected: OperationResult[];
-}
-
-/** status() return value */
-export interface WalletStatus {
- staged: Operation[];
- pendingMessage: string | null;
- head: CommitHash | null;
- commitCount: number;
-}
-
-/** Operation summary (similar to file changes in git log --stat) */
-export interface OperationSummary {
- symbol: string;
- action: OperationAction;
- /** Change description, e.g. "long +$1000" or "closed (pnl: +$50)" */
- change: string;
- /** Execution status */
- status: OperationStatus;
-}
-
-/** Commit info returned by log() (with operation summaries) */
-export interface CommitLogEntry {
- hash: CommitHash;
- parentHash: CommitHash | null;
- message: string;
- timestamp: string;
- round?: number;
- /** List of operation summaries (similar to git log --stat) */
- operations: OperationSummary[];
-}
-
-// ==================== Export State ====================
-
-/** Wallet export state (saved to snapshot) */
-export interface WalletExportState {
- commits: WalletCommit[];
- head: CommitHash | null;
-}
-
-// ==================== Sync ====================
-
-/** Order status update (used by walletSync) */
-export interface OrderStatusUpdate {
- orderId: string;
- symbol: string;
- previousStatus: OperationStatus;
- currentStatus: OperationStatus;
- filledPrice?: number;
- filledSize?: number;
-}
-
-/** sync() return value */
-export interface SyncResult {
- hash: CommitHash;
- updatedCount: number;
- updates: OrderStatusUpdate[];
-}
-
-// ==================== Simulate Price Change ====================
-
-/** Price change input */
-export interface PriceChangeInput {
- /** Trading pair (e.g. "BTC/USD") or "all" */
- symbol: string;
- /** Price change: "@88000" (absolute) or "+10%" / "-5%" (relative) */
- change: string;
-}
-
-/** Current position state (for simulation) */
-export interface SimulationPositionCurrent {
- symbol: string;
- side: 'long' | 'short';
- size: number;
- entryPrice: number;
- currentPrice: number;
- unrealizedPnL: number;
- positionValue: number;
-}
-
-/** Position state after simulation */
-export interface SimulationPositionAfter {
- symbol: string;
- side: 'long' | 'short';
- size: number;
- entryPrice: number;
- simulatedPrice: number;
- unrealizedPnL: number;
- positionValue: number;
- pnlChange: number;
- priceChangePercent: string;
-}
-
-/** Simulation result */
-export interface SimulatePriceChangeResult {
- success: boolean;
- error?: string;
- currentState: {
- equity: number;
- unrealizedPnL: number;
- totalPnL: number;
- positions: SimulationPositionCurrent[];
- };
- simulatedState: {
- equity: number;
- unrealizedPnL: number;
- totalPnL: number;
- positions: SimulationPositionAfter[];
- };
- summary: {
- totalPnLChange: number;
- equityChange: number;
- equityChangePercent: string;
- worstCase: string;
- };
-}
diff --git a/src/extension/crypto/adapter.ts b/src/extension/crypto/adapter.ts
deleted file mode 100644
index 4e048bb8..00000000
--- a/src/extension/crypto/adapter.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-/**
- * Crypto AI Tools
- *
- * cryptoSearch:
- * 直接透传 query 给 yfinance 的在线搜索,不需要本地缓存。
- * yfinance 自带模糊匹配,同时保证搜索结果和 K 线数据源一致。
- */
-
-import { tool } from 'ai'
-import { z } from 'zod'
-import type { OpenBBCryptoClient } from '@/openbb/crypto/client'
-
-export function createCryptoTools(cryptoClient: OpenBBCryptoClient) {
- return {
- cryptoSearch: tool({
- description: `Search for cryptocurrency symbols by keyword.
-
-Matches against symbol, name, and exchange via Yahoo Finance fuzzy search.
-Examples: "BTC" → BTCUSD, BTCEUR; "ethereum" → ETHUSD; "sol" → SOLUSD.
-
-Use this FIRST to find the correct symbol before querying any crypto data.`,
- inputSchema: z.object({
- query: z.string().describe('Keyword to search, e.g. "BTC", "ethereum", "solana"'),
- }),
- execute: async ({ query }) => {
- const results = await cryptoClient.search({ query, provider: 'yfinance' })
- if (results.length === 0) {
- return { results: [], message: `No crypto matching "${query}". Try a different keyword.` }
- }
- return { results, count: results.length }
- },
- }),
- }
-}
diff --git a/src/extension/crypto/index.ts b/src/extension/crypto/index.ts
deleted file mode 100644
index 95025515..00000000
--- a/src/extension/crypto/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { createCryptoTools } from './adapter'
diff --git a/src/extension/currency/adapter.ts b/src/extension/currency/adapter.ts
deleted file mode 100644
index a6b15379..00000000
--- a/src/extension/currency/adapter.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-/**
- * Currency AI Tools
- *
- * currencySearch:
- * 透传 query 给 yfinance 在线搜索,只返回 XXXUSD 交易对。
- * 统一以美元计价,方便比较各币种相对美元的升贬值。
- */
-
-import { tool } from 'ai'
-import { z } from 'zod'
-import type { OpenBBCurrencyClient } from '@/openbb/currency/client'
-
-export function createCurrencyTools(currencyClient: OpenBBCurrencyClient) {
- return {
- currencySearch: tool({
- description: `Search for currency pairs by keyword. Only returns XXXUSD pairs (priced in USD).
-
-Examples: "EUR" → EURUSD; "JPY" → JPYUSD; "GBP" → GBPUSD.
-The price represents how many USD one unit of the currency is worth — rising means appreciation against USD.
-
-Use this FIRST to find the correct symbol before querying any currency data.`,
- inputSchema: z.object({
- query: z.string().describe('Currency keyword to search, e.g. "EUR", "JPY", "pound"'),
- }),
- execute: async ({ query }) => {
- const all = await currencyClient.search({ query, provider: 'yfinance' })
- const results = all.filter((r) => {
- const sym = (r as Record).symbol as string | undefined
- return sym?.endsWith('USD')
- })
- if (results.length === 0) {
- return { results: [], message: `No USD pairs matching "${query}". Try a different keyword.` }
- }
- return { results, count: results.length }
- },
- }),
- }
-}
diff --git a/src/extension/currency/index.ts b/src/extension/currency/index.ts
deleted file mode 100644
index 40a0c024..00000000
--- a/src/extension/currency/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { createCurrencyTools } from './adapter'
diff --git a/src/extension/equity/adapter.ts b/src/extension/equity/adapter.ts
index c6029ab4..5f24c57e 100644
--- a/src/extension/equity/adapter.ts
+++ b/src/extension/equity/adapter.ts
@@ -1,14 +1,6 @@
/**
* Equity AI Tools
*
- * equitySearch:
- * 为了实现正则搜索,我们在启动时从 OpenBB API 拉取全量 symbol 列表并缓存到
- * data/cache/equity/symbols.json。搜索在本地内存中进行,不依赖 API。
- * 当前缓存的数据源(免费,不需要 API key):
- * - SEC (sec): ~10,000 美股上市公司,来自 SEC EDGAR
- * - TMX (tmx): ~3,600 加拿大上市公司,来自多伦多交易所
- * 扩展方法:在 SymbolIndex 的 SOURCES 数组中添加新的 provider 即可。
- *
* equityGetProfile / equityGetFinancials / equityGetRatios / equityGetEstimates /
* equityGetEarningsCalendar / equityGetInsiderTrading / equityDiscover:
* 透传到 OpenBB equity API,为 AI 提供基本面和市场发现能力。
@@ -16,40 +8,17 @@
import { tool } from 'ai'
import { z } from 'zod'
-import type { SymbolIndex } from '@/openbb/equity/SymbolIndex'
import type { OpenBBEquityClient } from '@/openbb/equity/client'
-export function createEquityTools(symbolIndex: SymbolIndex, equityClient: OpenBBEquityClient) {
+export function createEquityTools(equityClient: OpenBBEquityClient) {
return {
- equitySearch: tool({
- description: `Search for equity symbols by regex pattern or keyword.
-
-Matches against both ticker symbol and company name (case-insensitive).
-Supports full regex syntax: "^BRK\\." for BRK.A/BRK.B, "semiconductor" for all semiconductor companies, "^AA" for all tickers starting with AA.
-
-If the regex is invalid, falls back to simple substring matching.
-
-Use this FIRST to find the correct symbol before querying any equity data.`,
- inputSchema: z.object({
- pattern: z.string().describe('Regex pattern or keyword to match against symbol and company name'),
- limit: z.number().int().positive().optional().describe('Max results to return (default: 20)'),
- }),
- execute: ({ pattern, limit }) => {
- const results = symbolIndex.search(pattern, limit)
- if (results.length === 0) {
- return { results: [], message: `No symbols matching "${pattern}". Try a broader pattern.` }
- }
- return { results, count: results.length }
- },
- }),
-
equityGetProfile: tool({
description: `Get company profile and key valuation metrics for a stock.
Returns company overview (name, sector, industry, description, website, CEO, employees)
combined with key metrics (market cap, PE ratio, PB ratio, EV/EBITDA, dividend yield, etc.).
-Use equitySearch first to resolve the correct symbol.`,
+If unsure about the symbol, use marketSearchForResearch to find it.`,
inputSchema: z.object({
symbol: z.string().describe('Ticker symbol, e.g. "AAPL", "MSFT"'),
}),
@@ -68,7 +37,7 @@ Use equitySearch first to resolve the correct symbol.`,
Returns income statement, balance sheet, or cash flow statement depending on the "type" parameter.
Each entry is one fiscal period (quarterly or annual).
-Use equitySearch first to resolve the correct symbol.`,
+If unsure about the symbol, use marketSearchForResearch to find it.`,
inputSchema: z.object({
symbol: z.string().describe('Ticker symbol, e.g. "AAPL"'),
type: z.enum(['income', 'balance', 'cash']).describe('Statement type: "income" for income statement, "balance" for balance sheet, "cash" for cash flow'),
@@ -98,7 +67,7 @@ Returns profitability ratios (ROE, ROA, gross margin, net margin, operating marg
liquidity ratios (current ratio, quick ratio), leverage ratios (debt/equity),
and efficiency ratios (asset turnover, inventory turnover).
-Use equitySearch first to resolve the correct symbol.`,
+If unsure about the symbol, use marketSearchForResearch to find it.`,
inputSchema: z.object({
symbol: z.string().describe('Ticker symbol, e.g. "AAPL"'),
period: z.enum(['annual', 'quarter']).optional().describe('Fiscal period (default: annual)'),
@@ -118,7 +87,7 @@ Use equitySearch first to resolve the correct symbol.`,
Returns consensus rating (buy/hold/sell counts), average target price, and EPS estimates.
Useful for understanding how the market views a stock's prospects.
-Use equitySearch first to resolve the correct symbol.`,
+If unsure about the symbol, use marketSearchForResearch to find it.`,
inputSchema: z.object({
symbol: z.string().describe('Ticker symbol, e.g. "AAPL"'),
}),
@@ -154,7 +123,7 @@ Can be queried by symbol (specific company) or by date range (market-wide).`,
Returns recent buy/sell transactions by company executives, directors, and major shareholders.
Insider buying is often a strong bullish signal; large insider selling may warrant caution.
-Use equitySearch first to resolve the correct symbol.`,
+If unsure about the symbol, use marketSearchForResearch to find it.`,
inputSchema: z.object({
symbol: z.string().describe('Ticker symbol, e.g. "AAPL"'),
limit: z.number().int().positive().optional().describe('Number of transactions to return (default: 20)'),
diff --git a/src/extension/market/adapter.ts b/src/extension/market/adapter.ts
new file mode 100644
index 00000000..1b89bd88
--- /dev/null
+++ b/src/extension/market/adapter.ts
@@ -0,0 +1,70 @@
+/**
+ * Market Search AI Tool
+ *
+ * marketSearchForResearch:
+ * 统一的市场数据 symbol 搜索入口,跨 equity / crypto / currency 三个资产类别。
+ * - equity: 本地 SEC/TMX 缓存,正则匹配,零延迟
+ * - crypto: yfinance 在线模糊搜索
+ * - currency: yfinance 在线模糊搜索,只返回 XXXUSD 对
+ * 返回值带 assetClass 字段归属。
+ */
+
+import { tool } from 'ai'
+import { z } from 'zod'
+import type { SymbolIndex } from '@/openbb/equity/SymbolIndex'
+import type { OpenBBCryptoClient } from '@/openbb/crypto/client'
+import type { OpenBBCurrencyClient } from '@/openbb/currency/client'
+
+export function createMarketSearchTools(
+ symbolIndex: SymbolIndex,
+ cryptoClient: OpenBBCryptoClient,
+ currencyClient: OpenBBCurrencyClient,
+) {
+ return {
+ marketSearchForResearch: tool({
+ description: `Search for symbols across all asset classes (equities, crypto, currencies) for market data research.
+
+Returns matching symbols with assetClass attribution ("equity", "crypto", or "currency").
+Equity results come from SEC/TMX listings (~13k US/CA stocks); crypto and currency results
+come from Yahoo Finance fuzzy search. Currency results are filtered to XXXUSD pairs only.
+
+If unsure about the symbol, use this to find the correct one for market data tools
+(equityGetProfile, equityGetFinancials, calculateIndicator, etc.).
+This is NOT for trading — use searchContracts to find broker-tradeable contracts.`,
+ inputSchema: z.object({
+ query: z.string().describe('Keyword to search, e.g. "AAPL", "bitcoin", "EUR"'),
+ limit: z.number().int().positive().optional().describe('Max results per asset class (default: 20)'),
+ }),
+ execute: async ({ query, limit }) => {
+ const cap = limit ?? 20
+
+ // equity: 本地同步搜索
+ const equityResults = symbolIndex.search(query, cap).map((r) => ({ ...r, assetClass: 'equity' as const }))
+
+ // crypto + currency: yfinance 在线搜索,并行,容错
+ const [cryptoSettled, currencySettled] = await Promise.allSettled([
+ cryptoClient.search({ query, provider: 'yfinance' }),
+ currencyClient.search({ query, provider: 'yfinance' }),
+ ])
+
+ const cryptoResults = (cryptoSettled.status === 'fulfilled' ? cryptoSettled.value : []).map((r) => ({
+ ...r,
+ assetClass: 'crypto' as const,
+ }))
+
+ const currencyResults = (currencySettled.status === 'fulfilled' ? currencySettled.value : [])
+ .filter((r) => {
+ const sym = (r as Record).symbol as string | undefined
+ return sym?.endsWith('USD')
+ })
+ .map((r) => ({ ...r, assetClass: 'currency' as const }))
+
+ const results = [...equityResults, ...cryptoResults, ...currencyResults]
+ if (results.length === 0) {
+ return { results: [], message: `No symbols matching "${query}". Try a different keyword.` }
+ }
+ return { results, count: results.length }
+ },
+ }),
+ }
+}
diff --git a/src/extension/market/index.ts b/src/extension/market/index.ts
new file mode 100644
index 00000000..2466fdc8
--- /dev/null
+++ b/src/extension/market/index.ts
@@ -0,0 +1 @@
+export { createMarketSearchTools } from './adapter'
diff --git a/src/extension/news/adapter.ts b/src/extension/news/adapter.ts
index b551dc52..f8eec646 100644
--- a/src/extension/news/adapter.ts
+++ b/src/extension/news/adapter.ts
@@ -35,7 +35,7 @@ Useful for understanding macro sentiment, geopolitical events, and market-moving
Returns recent news articles related to the given stock symbol.
Essential for understanding price movements, earnings reactions, and corporate events.
-Use equitySearch first to resolve the correct symbol.`,
+If unsure about the symbol, use marketSearchForResearch to find it.`,
inputSchema: z.object({
symbol: z.string().describe('Ticker symbol, e.g. "AAPL", "TSLA"'),
limit: z.number().int().positive().optional().describe('Number of articles to return (default: 20)'),
diff --git a/src/extension/securities-trading/adapter.ts b/src/extension/securities-trading/adapter.ts
deleted file mode 100644
index 52164207..00000000
--- a/src/extension/securities-trading/adapter.ts
+++ /dev/null
@@ -1,300 +0,0 @@
-import { tool } from 'ai';
-import { z } from 'zod';
-import type { ISecuritiesTradingEngine } from './interfaces';
-import type { ISecWallet } from './wallet/interfaces';
-import type { OrderStatusUpdate, WalletState } from './wallet/types';
-import { createSecWalletToolsImpl } from './wallet/adapter';
-
-/**
- * Create securities trading AI tools (market interaction + wallet management)
- *
- * Wallet operations (git-like decision tracking):
- * - secWalletCommit, secWalletPush, secWalletLog, secWalletShow, secWalletStatus, secWalletSync, secSimulatePriceChange
- *
- * Trading operations (staged via wallet):
- * - secPlaceOrder, secClosePosition, secCancelOrder
- *
- * Query operations (direct):
- * - secGetPortfolio, secGetOrders, secGetAccount, secGetMarketClock
- */
-export function createSecuritiesTradingTools(
- tradingEngine: ISecuritiesTradingEngine,
- wallet: ISecWallet,
- getWalletState?: () => Promise,
-) {
- return {
- // ==================== Wallet operations ====================
- ...createSecWalletToolsImpl(wallet),
-
- // ==================== Sync ====================
-
- secWalletSync: tool({
- description: `
-Sync pending order statuses from broker (like "git pull").
-
-Checks all pending orders from previous commits and fetches their latest
-status from the broker. Creates a sync commit recording any changes.
-
-Use this after placing limit/stop orders to check if they've been filled.
- `.trim(),
- inputSchema: z.object({}),
- execute: async () => {
- if (!getWalletState) {
- return { message: 'Securities broker not connected. Cannot sync.', updatedCount: 0 };
- }
-
- const pendingOrders = wallet.getPendingOrderIds();
- if (pendingOrders.length === 0) {
- return { message: 'No pending orders to sync.', updatedCount: 0 };
- }
-
- const brokerOrders = await tradingEngine.getOrders();
- const updates: OrderStatusUpdate[] = [];
-
- for (const { orderId, symbol } of pendingOrders) {
- const brokerOrder = brokerOrders.find(o => o.id === orderId);
- if (!brokerOrder) continue;
-
- const newStatus = brokerOrder.status;
- if (newStatus !== 'pending') {
- updates.push({
- orderId,
- symbol,
- previousStatus: 'pending',
- currentStatus: newStatus,
- filledPrice: brokerOrder.filledPrice,
- filledQty: brokerOrder.filledQty,
- });
- }
- }
-
- if (updates.length === 0) {
- return {
- message: `All ${pendingOrders.length} order(s) still pending.`,
- updatedCount: 0,
- };
- }
-
- const state = await getWalletState();
- return await wallet.sync(updates, state);
- },
- }),
-
- // ==================== Trading operations (staged to Wallet) ====================
-
- secPlaceOrder: tool({
- description: `
-Stage a securities order in wallet (will execute on secWalletPush).
-
-BEFORE placing orders, you SHOULD:
-1. Check secWalletLog({ symbol }) to review your history for THIS symbol
-2. Check secGetPortfolio to see current holdings
-3. Verify this trade aligns with your stated strategy
-
-Supports two modes:
-- qty-based: Specify number of shares (supports fractional, e.g. 0.5)
-- notional-based: Specify USD amount (e.g. $1000 of AAPL)
-
-For SELLING holdings, use secClosePosition tool instead.
-
-NOTE: This stages the operation. Call secWalletCommit + secWalletPush to execute.
- `.trim(),
- inputSchema: z.object({
- symbol: z.string().describe('Ticker symbol, e.g. "AAPL", "SPY"'),
- side: z.enum(['buy', 'sell']).describe('Buy or sell'),
- type: z
- .enum(['market', 'limit', 'stop', 'stop_limit'])
- .describe('Order type'),
- qty: z
- .number()
- .positive()
- .optional()
- .describe('Number of shares (supports fractional). Mutually exclusive with notional.'),
- notional: z
- .number()
- .positive()
- .optional()
- .describe('Dollar amount to invest (e.g. 1000 = $1000 of the stock). Mutually exclusive with qty.'),
- price: z
- .number()
- .positive()
- .optional()
- .describe('Limit price (required for limit and stop_limit orders)'),
- stopPrice: z
- .number()
- .positive()
- .optional()
- .describe('Stop trigger price (required for stop and stop_limit orders)'),
- timeInForce: z
- .enum(['day', 'gtc', 'ioc', 'fok'])
- .default('day')
- .describe('Time in force (default: day)'),
- extendedHours: z
- .boolean()
- .optional()
- .describe('Allow pre-market and after-hours trading'),
- }),
- execute: ({
- symbol,
- side,
- type,
- qty,
- notional,
- price,
- stopPrice,
- timeInForce,
- extendedHours,
- }) => {
- return wallet.add({
- action: 'placeOrder',
- params: { symbol, side, type, qty, notional, price, stopPrice, timeInForce, extendedHours },
- });
- },
- }),
-
- secClosePosition: tool({
- description: `
-Stage a securities position close in wallet (will execute on secWalletPush).
-
-This is the preferred way to sell holdings instead of using secPlaceOrder with side="sell".
-
-NOTE: This stages the operation. Call secWalletCommit + secWalletPush to execute.
- `.trim(),
- inputSchema: z.object({
- symbol: z.string().describe('Ticker symbol, e.g. "AAPL"'),
- qty: z
- .number()
- .positive()
- .optional()
- .describe('Number of shares to sell (default: sell all)'),
- }),
- execute: ({ symbol, qty }) => {
- return wallet.add({
- action: 'closePosition',
- params: { symbol, qty },
- });
- },
- }),
-
- secCancelOrder: tool({
- description: `
-Stage an order cancellation in wallet (will execute on secWalletPush).
-
-NOTE: This stages the operation. Call secWalletCommit + secWalletPush to execute.
- `.trim(),
- inputSchema: z.object({
- orderId: z.string().describe('Order ID to cancel'),
- }),
- execute: ({ orderId }) => {
- return wallet.add({
- action: 'cancelOrder',
- params: { orderId },
- });
- },
- }),
-
- // ==================== Query operations (no staging needed) ====================
-
- secGetPortfolio: tool({
- description: `Query current securities portfolio holdings.
-
-Each holding includes:
-- symbol, side, qty, avgEntryPrice, currentPrice
-- marketValue: Current market value
-- unrealizedPnL / unrealizedPnLPercent: Unrealized profit/loss
-- costBasis: Total cost basis
-- percentageOfEquity: This holding's value as percentage of total equity
-- percentageOfPortfolio: This holding's value as percentage of total portfolio
-
-IMPORTANT: If result is an empty array [], you have no holdings.`,
- inputSchema: z.object({
- symbol: z
- .string()
- .optional()
- .describe('Filter by ticker (e.g. "AAPL"), or omit for all holdings'),
- }),
- execute: async ({ symbol }) => {
- const allHoldings = await tradingEngine.getPortfolio();
- const account = await tradingEngine.getAccount();
-
- const totalMarketValue = allHoldings.reduce(
- (sum, h) => sum + h.marketValue,
- 0,
- );
-
- const holdingsWithPercentage = allHoldings.map((holding) => {
- const percentOfEquity =
- account.equity > 0
- ? (holding.marketValue / account.equity) * 100
- : 0;
- const percentOfPortfolio =
- totalMarketValue > 0
- ? (holding.marketValue / totalMarketValue) * 100
- : 0;
-
- return {
- ...holding,
- percentageOfEquity: `${percentOfEquity.toFixed(1)}%`,
- percentageOfPortfolio: `${percentOfPortfolio.toFixed(1)}%`,
- };
- });
-
- const filtered = (!symbol || symbol === 'all')
- ? holdingsWithPercentage
- : holdingsWithPercentage.filter((h) => h.symbol === symbol);
-
- if (filtered.length === 0) {
- return {
- holdings: [],
- message: 'No holdings. Your securities portfolio is empty.',
- };
- }
-
- return filtered;
- },
- }),
-
- secGetOrders: tool({
- description: 'Query securities order history (filled, pending, cancelled)',
- inputSchema: z.object({}),
- execute: async () => {
- return await tradingEngine.getOrders();
- },
- }),
-
- secGetAccount: tool({
- description:
- 'Query securities account info (cash, portfolioValue, equity, buyingPower, unrealizedPnL, realizedPnL, dayTradeCount).',
- inputSchema: z.object({}),
- execute: async () => {
- return await tradingEngine.getAccount();
- },
- }),
-
- secGetQuote: tool({
- description: `Query the latest quote/price for a stock symbol.
-
-Returns real-time market data from the broker:
-- last: last traded price
-- bid/ask: current best bid and ask
-- volume: today's trading volume
-
-Use this to check current prices before placing orders.`,
- inputSchema: z.object({
- symbol: z.string().describe('Ticker symbol, e.g. "AAPL", "SPY"'),
- }),
- execute: async ({ symbol }) => {
- return await tradingEngine.getQuote(symbol);
- },
- }),
-
- secGetMarketClock: tool({
- description:
- 'Get current market clock status (isOpen, nextOpen, nextClose). Use this to check if the market is currently open for trading.',
- inputSchema: z.object({}),
- execute: async () => {
- return await tradingEngine.getMarketClock();
- },
- }),
- };
-}
diff --git a/src/extension/securities-trading/factory.ts b/src/extension/securities-trading/factory.ts
deleted file mode 100644
index fbeeb335..00000000
--- a/src/extension/securities-trading/factory.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-/**
- * Securities Trading Engine Factory
- *
- * Instantiate the corresponding securities trading engine provider based on config
- */
-
-import type { ISecuritiesTradingEngine } from './interfaces.js';
-import type { Config } from '../../core/config.js';
-
-export interface SecuritiesTradingEngineResult {
- engine: ISecuritiesTradingEngine;
- close: () => Promise;
-}
-
-/**
- * Create securities trading engine
- *
- * @returns engine instance, or null (provider = 'none')
- */
-export async function createSecuritiesTradingEngine(
- config: Config,
-): Promise {
- const providerConfig = config.securities.provider;
-
- switch (providerConfig.type) {
- case 'none':
- return null;
-
- case 'alpaca': {
- // Dynamic import to avoid loading Alpaca SDK when not needed
- const { AlpacaTradingEngine } = await import('./providers/alpaca/index.js');
-
- const apiKey = providerConfig.apiKey;
- const secretKey = providerConfig.secretKey;
-
- if (!apiKey || !secretKey) {
- throw new Error(
- 'apiKey and secretKey must be configured for Alpaca provider (Settings → Securities)',
- );
- }
-
- const engine = new AlpacaTradingEngine({
- apiKey,
- secretKey,
- paper: providerConfig.paper,
- });
-
- await engine.init();
-
- return {
- engine,
- close: () => engine.close(),
- };
- }
-
- default:
- throw new Error(`Unknown securities provider: ${(providerConfig as { type: string }).type}`);
- }
-}
diff --git a/src/extension/securities-trading/guards/cooldown.ts b/src/extension/securities-trading/guards/cooldown.ts
deleted file mode 100644
index 8cbfbbed..00000000
--- a/src/extension/securities-trading/guards/cooldown.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import type { SecOperationGuard, SecGuardContext } from './types.js';
-
-const DEFAULT_MIN_INTERVAL_MS = 60_000;
-
-export class SecCooldownGuard implements SecOperationGuard {
- readonly name = 'cooldown';
- private minIntervalMs: number;
- private lastTradeTime = new Map();
-
- constructor(options: Record) {
- this.minIntervalMs = Number(options.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS);
- }
-
- check(ctx: SecGuardContext): string | null {
- if (ctx.operation.action !== 'placeOrder') return null;
-
- const symbol = ctx.operation.params.symbol as string;
- const now = Date.now();
- const lastTime = this.lastTradeTime.get(symbol);
-
- if (lastTime != null) {
- const elapsed = now - lastTime;
- if (elapsed < this.minIntervalMs) {
- const remaining = Math.ceil((this.minIntervalMs - elapsed) / 1000);
- return `Cooldown active for ${symbol}: ${remaining}s remaining`;
- }
- }
-
- this.lastTradeTime.set(symbol, now);
- return null;
- }
-}
diff --git a/src/extension/securities-trading/guards/guard-pipeline.ts b/src/extension/securities-trading/guards/guard-pipeline.ts
deleted file mode 100644
index 55ea493a..00000000
--- a/src/extension/securities-trading/guards/guard-pipeline.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-/**
- * Securities Guard Pipeline
- *
- * Assembles a SecGuardContext from the engine, then passes it through the
- * guard chain. Guards themselves never see the engine.
- */
-
-import type { Operation } from '../wallet/types.js';
-import type { ISecuritiesTradingEngine } from '../interfaces.js';
-import type { SecOperationGuard, SecGuardContext } from './types.js';
-
-export function createSecGuardPipeline(
- dispatcher: (op: Operation) => Promise,
- engine: ISecuritiesTradingEngine,
- guards: SecOperationGuard[],
-): (op: Operation) => Promise {
- if (guards.length === 0) return dispatcher;
-
- return async (op: Operation): Promise => {
- const [holdings, account] = await Promise.all([
- engine.getPortfolio(),
- engine.getAccount(),
- ]);
-
- const ctx: SecGuardContext = { operation: op, holdings, account };
-
- for (const guard of guards) {
- const rejection = await guard.check(ctx);
- if (rejection != null) {
- return { success: false, error: `[guard:${guard.name}] ${rejection}` };
- }
- }
-
- return dispatcher(op);
- };
-}
diff --git a/src/extension/securities-trading/guards/index.ts b/src/extension/securities-trading/guards/index.ts
deleted file mode 100644
index 165b144f..00000000
--- a/src/extension/securities-trading/guards/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export type { SecOperationGuard, SecGuardContext, SecGuardRegistryEntry } from './types.js';
-export { createSecGuardPipeline } from './guard-pipeline.js';
-export { resolveSecGuards, registerSecGuard } from './registry.js';
diff --git a/src/extension/securities-trading/guards/max-position-size.ts b/src/extension/securities-trading/guards/max-position-size.ts
deleted file mode 100644
index 72a5627f..00000000
--- a/src/extension/securities-trading/guards/max-position-size.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import type { SecOperationGuard, SecGuardContext } from './types.js';
-
-const DEFAULT_MAX_PERCENT = 25;
-
-export class SecMaxPositionSizeGuard implements SecOperationGuard {
- readonly name = 'max-position-size';
- private maxPercent: number;
-
- constructor(options: Record) {
- this.maxPercent = Number(options.maxPercentOfEquity ?? DEFAULT_MAX_PERCENT);
- }
-
- check(ctx: SecGuardContext): string | null {
- if (ctx.operation.action !== 'placeOrder') return null;
-
- const { holdings, account, operation } = ctx;
- const symbol = operation.params.symbol as string;
-
- const existing = holdings.find(h => h.symbol === symbol);
- const currentValue = existing?.marketValue ?? 0;
-
- // Estimate added value from order params
- const notional = operation.params.notional as number | undefined;
- const qty = operation.params.qty as number | undefined;
-
- let addedValue = 0;
- if (notional) {
- addedValue = notional;
- } else if (qty && existing) {
- addedValue = qty * existing.currentPrice;
- }
- // If we can't estimate (new symbol + qty-based without holding), allow — broker will validate
-
- if (addedValue === 0) return null;
-
- const projectedValue = currentValue + addedValue;
- const percent = account.equity > 0 ? (projectedValue / account.equity) * 100 : 0;
-
- if (percent > this.maxPercent) {
- return `Position for ${symbol} would be ${percent.toFixed(1)}% of equity (limit: ${this.maxPercent}%)`;
- }
-
- return null;
- }
-}
diff --git a/src/extension/securities-trading/guards/registry.ts b/src/extension/securities-trading/guards/registry.ts
deleted file mode 100644
index 4b9a25e9..00000000
--- a/src/extension/securities-trading/guards/registry.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import type { SecOperationGuard, SecGuardRegistryEntry } from './types.js';
-import { SecCooldownGuard } from './cooldown.js';
-import { SecMaxPositionSizeGuard } from './max-position-size.js';
-import { SecSymbolWhitelistGuard } from './symbol-whitelist.js';
-
-const builtinGuards: SecGuardRegistryEntry[] = [
- { type: 'max-position-size', create: (opts) => new SecMaxPositionSizeGuard(opts) },
- { type: 'cooldown', create: (opts) => new SecCooldownGuard(opts) },
- { type: 'symbol-whitelist', create: (opts) => new SecSymbolWhitelistGuard(opts) },
-];
-
-const registry = new Map(
- builtinGuards.map(g => [g.type, g.create]),
-);
-
-/** Register a custom guard type (for third-party extensions) */
-export function registerSecGuard(entry: SecGuardRegistryEntry): void {
- registry.set(entry.type, entry.create);
-}
-
-/** Resolve config entries into guard instances via the registry */
-export function resolveSecGuards(
- configs: Array<{ type: string; options?: Record }>,
-): SecOperationGuard[] {
- const guards: SecOperationGuard[] = [];
- for (const cfg of configs) {
- const factory = registry.get(cfg.type);
- if (!factory) {
- console.warn(`sec guard: unknown type "${cfg.type}", skipped`);
- continue;
- }
- guards.push(factory(cfg.options ?? {}));
- }
- return guards;
-}
diff --git a/src/extension/securities-trading/guards/symbol-whitelist.ts b/src/extension/securities-trading/guards/symbol-whitelist.ts
deleted file mode 100644
index 4ad8724b..00000000
--- a/src/extension/securities-trading/guards/symbol-whitelist.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import type { SecOperationGuard, SecGuardContext } from './types.js';
-
-export class SecSymbolWhitelistGuard implements SecOperationGuard {
- readonly name = 'symbol-whitelist';
- private allowed: Set;
-
- constructor(options: Record) {
- const symbols = options.symbols as string[] | undefined;
- if (!symbols || symbols.length === 0) {
- throw new Error('symbol-whitelist guard requires a non-empty "symbols" array in options');
- }
- this.allowed = new Set(symbols);
- }
-
- check(ctx: SecGuardContext): string | null {
- const symbol = ctx.operation.params.symbol as string | undefined;
- if (!symbol) return null;
-
- if (!this.allowed.has(symbol)) {
- return `Symbol ${symbol} is not in the allowed list`;
- }
- return null;
- }
-}
diff --git a/src/extension/securities-trading/guards/types.ts b/src/extension/securities-trading/guards/types.ts
deleted file mode 100644
index 15686388..00000000
--- a/src/extension/securities-trading/guards/types.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import type { Operation } from '../wallet/types.js';
-import type { SecHolding, SecAccountInfo } from '../interfaces.js';
-
-/** Read-only context assembled by the pipeline, consumed by guards */
-export interface SecGuardContext {
- readonly operation: Operation;
- readonly holdings: readonly SecHolding[];
- readonly account: Readonly;
-}
-
-/** A guard that can reject operations. Returns null to allow, or a rejection reason string. */
-export interface SecOperationGuard {
- readonly name: string;
- check(ctx: SecGuardContext): Promise | string | null;
-}
-
-/** Registry entry: type identifier + factory function */
-export interface SecGuardRegistryEntry {
- type: string;
- create(options: Record): SecOperationGuard;
-}
diff --git a/src/extension/securities-trading/index.ts b/src/extension/securities-trading/index.ts
deleted file mode 100644
index aa3f2cf5..00000000
--- a/src/extension/securities-trading/index.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-// Extension adapter
-export { createSecuritiesTradingTools } from './adapter';
-
-// Trading domain types
-export type {
- ISecuritiesTradingEngine,
- SecOrderRequest,
- SecOrderResult,
- SecOrder,
- SecHolding,
- SecAccountInfo,
- MarketClock,
-} from './interfaces';
-
-// Wallet domain
-export { SecWallet } from './wallet/SecWallet';
-export type { ISecWallet, SecWalletConfig } from './wallet/interfaces';
-export type {
- Operation as SecOperation,
- WalletCommit as SecWalletCommit,
- WalletExportState as SecWalletExportState,
- CommitHash as SecCommitHash,
- OrderStatusUpdate as SecOrderStatusUpdate,
- SyncResult as SecSyncResult,
-} from './wallet/types';
-
-// Provider infrastructure
-export { createSecuritiesTradingEngine } from './factory';
-export type { SecuritiesTradingEngineResult } from './factory';
-export { createSecOperationDispatcher } from './operation-dispatcher';
-export { createSecWalletStateBridge } from './wallet-state-bridge';
-
-// Guard pipeline
-export type { SecOperationGuard, SecGuardContext } from './guards/index';
-export { createSecGuardPipeline, resolveSecGuards, registerSecGuard } from './guards/index';
diff --git a/src/extension/securities-trading/interfaces.ts b/src/extension/securities-trading/interfaces.ts
deleted file mode 100644
index 27716e98..00000000
--- a/src/extension/securities-trading/interfaces.ts
+++ /dev/null
@@ -1,108 +0,0 @@
-/**
- * Securities Trading Engine interface definitions
- *
- * Traditional securities (US stocks, etc.) trading interfaces, fully independent from Crypto
- * Semantic differences: no leverage/margin/liquidation price, uses portfolio instead of position
- */
-
-// ==================== Core interfaces ====================
-
-export interface ISecuritiesTradingEngine {
- placeOrder(order: SecOrderRequest): Promise;
- getPortfolio(): Promise;
- getOrders(): Promise;
- getAccount(): Promise;
- cancelOrder(orderId: string): Promise;
- getMarketClock(): Promise;
- getQuote(symbol: string): Promise;
- /** Native close position. If not implemented, dispatcher falls back to reverse market order. */
- closePosition?(symbol: string, qty?: number): Promise;
-}
-
-// ==================== Orders ====================
-
-export interface SecOrderRequest {
- symbol: string;
- side: 'buy' | 'sell';
- type: 'market' | 'limit' | 'stop' | 'stop_limit';
- qty?: number;
- notional?: number;
- price?: number;
- stopPrice?: number;
- timeInForce: 'day' | 'gtc' | 'ioc' | 'fok';
- extendedHours?: boolean;
-}
-
-export interface SecOrderResult {
- success: boolean;
- orderId?: string;
- error?: string;
- message?: string;
- filledPrice?: number;
- filledQty?: number;
-}
-
-export interface SecOrder {
- id: string;
- symbol: string;
- side: 'buy' | 'sell';
- type: 'market' | 'limit' | 'stop' | 'stop_limit';
- qty: number;
- price?: number;
- stopPrice?: number;
- timeInForce: 'day' | 'gtc' | 'ioc' | 'fok';
- extendedHours?: boolean;
- status: 'pending' | 'filled' | 'cancelled' | 'rejected' | 'partially_filled';
- filledPrice?: number;
- filledQty?: number;
- filledAt?: Date;
- createdAt: Date;
- rejectReason?: string;
-}
-
-// ==================== Portfolio ====================
-
-export interface SecHolding {
- symbol: string;
- side: 'long' | 'short';
- qty: number;
- avgEntryPrice: number;
- currentPrice: number;
- marketValue: number;
- unrealizedPnL: number;
- unrealizedPnLPercent: number;
- costBasis: number;
-}
-
-// ==================== Account ====================
-
-export interface SecAccountInfo {
- cash: number;
- portfolioValue: number;
- equity: number;
- buyingPower: number;
- unrealizedPnL: number;
- realizedPnL: number;
- dayTradeCount?: number;
- dayTradingBuyingPower?: number;
-}
-
-// ==================== Quote ====================
-
-export interface SecQuote {
- symbol: string;
- last: number;
- bid: number;
- ask: number;
- volume: number;
- timestamp: Date;
-}
-
-// ==================== Market clock ====================
-
-export interface MarketClock {
- isOpen: boolean;
- nextOpen: Date;
- nextClose: Date;
- timestamp: Date;
-}
diff --git a/src/extension/securities-trading/operation-dispatcher.spec.ts b/src/extension/securities-trading/operation-dispatcher.spec.ts
deleted file mode 100644
index 4eaade68..00000000
--- a/src/extension/securities-trading/operation-dispatcher.spec.ts
+++ /dev/null
@@ -1,299 +0,0 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { createSecOperationDispatcher } from './operation-dispatcher.js';
-import type { ISecuritiesTradingEngine, SecHolding } from './interfaces.js';
-import type { Operation } from './wallet/types.js';
-
-// ==================== Mock Factory ====================
-
-function createMockEngine(overrides: Partial = {}): ISecuritiesTradingEngine {
- return {
- placeOrder: vi.fn().mockResolvedValue({
- success: true,
- orderId: 'sec-001',
- filledPrice: 150.25,
- filledQty: 10,
- }),
- getPortfolio: vi.fn().mockResolvedValue([]),
- getOrders: vi.fn().mockResolvedValue([]),
- getAccount: vi.fn().mockResolvedValue({
- cash: 50000, portfolioValue: 0, equity: 50000,
- buyingPower: 100000, unrealizedPnL: 0, realizedPnL: 0,
- }),
- cancelOrder: vi.fn().mockResolvedValue(true),
- getMarketClock: vi.fn().mockResolvedValue({
- isOpen: true, nextOpen: new Date(), nextClose: new Date(), timestamp: new Date(),
- }),
- getQuote: vi.fn().mockResolvedValue({
- symbol: 'AAPL', last: 150, bid: 149.99, ask: 150.01,
- volume: 5000000, timestamp: new Date(),
- }),
- // closePosition intentionally omitted (optional)
- ...overrides,
- };
-}
-
-function makeLongHolding(overrides: Partial = {}): SecHolding {
- return {
- symbol: 'AAPL', side: 'long', qty: 100,
- avgEntryPrice: 140, currentPrice: 150, marketValue: 15000,
- unrealizedPnL: 1000, unrealizedPnLPercent: 7.14, costBasis: 14000,
- ...overrides,
- };
-}
-
-function makeShortHolding(overrides: Partial = {}): SecHolding {
- return {
- symbol: 'TSLA', side: 'short', qty: 20,
- avgEntryPrice: 250, currentPrice: 240, marketValue: 4800,
- unrealizedPnL: 200, unrealizedPnLPercent: 4, costBasis: 5000,
- ...overrides,
- };
-}
-
-// ==================== Tests ====================
-
-describe('createSecOperationDispatcher', () => {
- let engine: ISecuritiesTradingEngine;
- let dispatch: (op: Operation) => Promise;
-
- beforeEach(() => {
- engine = createMockEngine();
- dispatch = createSecOperationDispatcher(engine);
- });
-
- // ==================== placeOrder ====================
-
- describe('placeOrder', () => {
- it('maps Operation params to SecOrderRequest', async () => {
- const op: Operation = {
- action: 'placeOrder',
- params: {
- symbol: 'AAPL', side: 'buy', type: 'limit',
- qty: 10, price: 145, timeInForce: 'gtc', extendedHours: true,
- },
- };
-
- await dispatch(op);
-
- expect(engine.placeOrder).toHaveBeenCalledWith({
- symbol: 'AAPL', side: 'buy', type: 'limit',
- qty: 10, notional: undefined, price: 145,
- stopPrice: undefined, timeInForce: 'gtc', extendedHours: true,
- });
- });
-
- it('defaults timeInForce to "day" when not specified', async () => {
- await dispatch({
- action: 'placeOrder',
- params: { symbol: 'AAPL', side: 'buy', type: 'market', qty: 5 },
- });
-
- expect(engine.placeOrder).toHaveBeenCalledWith(
- expect.objectContaining({ timeInForce: 'day' }),
- );
- });
-
- it('passes notional for dollar-amount orders', async () => {
- await dispatch({
- action: 'placeOrder',
- params: { symbol: 'SPY', side: 'buy', type: 'market', notional: 1000 },
- });
-
- expect(engine.placeOrder).toHaveBeenCalledWith(
- expect.objectContaining({ qty: undefined, notional: 1000 }),
- );
- });
-
- it('wraps successful filled result', async () => {
- const result = await dispatch({
- action: 'placeOrder',
- params: { symbol: 'AAPL', side: 'buy', type: 'market', qty: 10 },
- });
-
- expect(result).toEqual({
- success: true,
- error: undefined,
- order: {
- id: 'sec-001',
- status: 'filled',
- filledPrice: 150.25,
- filledQty: 10,
- },
- });
- });
-
- it('wraps successful pending result', async () => {
- engine = createMockEngine({
- placeOrder: vi.fn().mockResolvedValue({
- success: true, orderId: 'sec-002',
- filledPrice: undefined, filledQty: undefined,
- }),
- });
- dispatch = createSecOperationDispatcher(engine);
-
- const result = await dispatch({
- action: 'placeOrder',
- params: { symbol: 'AAPL', side: 'buy', type: 'limit', qty: 10, price: 140 },
- });
-
- expect(result).toEqual({
- success: true,
- error: undefined,
- order: { id: 'sec-002', status: 'pending', filledPrice: undefined, filledQty: undefined },
- });
- });
-
- it('wraps failed result', async () => {
- engine = createMockEngine({
- placeOrder: vi.fn().mockResolvedValue({
- success: false, error: 'Insufficient buying power',
- }),
- });
- dispatch = createSecOperationDispatcher(engine);
-
- const result = await dispatch({
- action: 'placeOrder',
- params: { symbol: 'AAPL', side: 'buy', type: 'market', qty: 99999 },
- });
-
- expect(result).toEqual({
- success: false,
- error: 'Insufficient buying power',
- order: undefined,
- });
- });
- });
-
- // ==================== closePosition - native path ====================
-
- describe('closePosition - native path', () => {
- it('calls engine.closePosition when it exists', async () => {
- const closePosition = vi.fn().mockResolvedValue({
- success: true, orderId: 'close-001', filledPrice: 151, filledQty: 100,
- });
- engine = createMockEngine({ closePosition });
- dispatch = createSecOperationDispatcher(engine);
-
- await dispatch({ action: 'closePosition', params: { symbol: 'AAPL' } });
-
- expect(closePosition).toHaveBeenCalledWith('AAPL', undefined);
- expect(engine.getPortfolio).not.toHaveBeenCalled();
- });
-
- it('passes qty to native closePosition', async () => {
- const closePosition = vi.fn().mockResolvedValue({
- success: true, orderId: 'close-002', filledPrice: 151, filledQty: 30,
- });
- engine = createMockEngine({ closePosition });
- dispatch = createSecOperationDispatcher(engine);
-
- await dispatch({ action: 'closePosition', params: { symbol: 'AAPL', qty: 30 } });
-
- expect(closePosition).toHaveBeenCalledWith('AAPL', 30);
- });
-
- it('wraps native result in standard format', async () => {
- const closePosition = vi.fn().mockResolvedValue({
- success: true, orderId: 'close-001', filledPrice: 151, filledQty: 100,
- });
- engine = createMockEngine({ closePosition });
- dispatch = createSecOperationDispatcher(engine);
-
- const result = await dispatch({ action: 'closePosition', params: { symbol: 'AAPL' } });
-
- expect(result).toEqual({
- success: true,
- error: undefined,
- order: { id: 'close-001', status: 'filled', filledPrice: 151, filledQty: 100 },
- });
- });
- });
-
- // ==================== closePosition - fallback path ====================
-
- describe('closePosition - fallback path', () => {
- it('places sell order for long holding', async () => {
- engine = createMockEngine({
- getPortfolio: vi.fn().mockResolvedValue([makeLongHolding()]),
- });
- dispatch = createSecOperationDispatcher(engine);
-
- await dispatch({ action: 'closePosition', params: { symbol: 'AAPL' } });
-
- expect(engine.placeOrder).toHaveBeenCalledWith({
- symbol: 'AAPL', side: 'sell', type: 'market', qty: 100, timeInForce: 'day',
- });
- });
-
- it('places buy order for short holding', async () => {
- engine = createMockEngine({
- getPortfolio: vi.fn().mockResolvedValue([makeShortHolding()]),
- });
- dispatch = createSecOperationDispatcher(engine);
-
- await dispatch({ action: 'closePosition', params: { symbol: 'TSLA' } });
-
- expect(engine.placeOrder).toHaveBeenCalledWith({
- symbol: 'TSLA', side: 'buy', type: 'market', qty: 20, timeInForce: 'day',
- });
- });
-
- it('uses specified partial qty', async () => {
- engine = createMockEngine({
- getPortfolio: vi.fn().mockResolvedValue([makeLongHolding()]),
- });
- dispatch = createSecOperationDispatcher(engine);
-
- await dispatch({ action: 'closePosition', params: { symbol: 'AAPL', qty: 25 } });
-
- expect(engine.placeOrder).toHaveBeenCalledWith(
- expect.objectContaining({ qty: 25 }),
- );
- });
-
- it('returns error when no holding exists', async () => {
- const result = await dispatch({
- action: 'closePosition', params: { symbol: 'AAPL' },
- });
-
- expect(result).toEqual({ success: false, error: 'No holding for AAPL' });
- expect(engine.placeOrder).not.toHaveBeenCalled();
- });
- });
-
- // ==================== cancelOrder ====================
-
- describe('cancelOrder', () => {
- it('returns success when cancellation succeeds', async () => {
- const result = await dispatch({
- action: 'cancelOrder', params: { orderId: 'sec-001' },
- });
-
- expect(engine.cancelOrder).toHaveBeenCalledWith('sec-001');
- expect(result).toEqual({ success: true, error: undefined });
- });
-
- it('returns error when cancellation fails', async () => {
- engine = createMockEngine({
- cancelOrder: vi.fn().mockResolvedValue(false),
- });
- dispatch = createSecOperationDispatcher(engine);
-
- const result = await dispatch({
- action: 'cancelOrder', params: { orderId: 'sec-999' },
- });
-
- expect(result).toEqual({ success: false, error: 'Failed to cancel order' });
- });
- });
-
- // ==================== unknown action ====================
-
- describe('unknown action', () => {
- it('throws for unknown action', async () => {
- await expect(
- dispatch({ action: 'adjustLeverage' as never, params: {} }),
- ).rejects.toThrow('Unknown operation action: adjustLeverage');
- });
- });
-});
diff --git a/src/extension/securities-trading/operation-dispatcher.ts b/src/extension/securities-trading/operation-dispatcher.ts
deleted file mode 100644
index 9589059d..00000000
--- a/src/extension/securities-trading/operation-dispatcher.ts
+++ /dev/null
@@ -1,93 +0,0 @@
-/**
- * Securities Operation Dispatcher
- *
- * Provider-agnostic bridge: Wallet Operation -> ISecuritiesTradingEngine method dispatch
- */
-
-import type { ISecuritiesTradingEngine, SecOrderRequest } from './interfaces.js';
-import type { Operation } from './wallet/types.js';
-
-export function createSecOperationDispatcher(engine: ISecuritiesTradingEngine) {
- return async (op: Operation): Promise => {
- switch (op.action) {
- case 'placeOrder': {
- const req: SecOrderRequest = {
- symbol: op.params.symbol as string,
- side: op.params.side as 'buy' | 'sell',
- type: op.params.type as SecOrderRequest['type'],
- qty: op.params.qty as number | undefined,
- notional: op.params.notional as number | undefined,
- price: op.params.price as number | undefined,
- stopPrice: op.params.stopPrice as number | undefined,
- timeInForce: (op.params.timeInForce as SecOrderRequest['timeInForce']) ?? 'day',
- extendedHours: op.params.extendedHours as boolean | undefined,
- };
-
- const result = await engine.placeOrder(req);
-
- return {
- success: result.success,
- error: result.error,
- order: result.success
- ? {
- id: result.orderId,
- status: result.filledPrice ? 'filled' : 'pending',
- filledPrice: result.filledPrice,
- filledQty: result.filledQty,
- }
- : undefined,
- };
- }
-
- case 'closePosition': {
- const symbol = op.params.symbol as string;
- const qty = op.params.qty as number | undefined;
-
- let result;
-
- if (engine.closePosition) {
- // Native close (e.g. Alpaca closePosition API)
- result = await engine.closePosition(symbol, qty);
- } else {
- // Fallback: reverse market order
- const portfolio = await engine.getPortfolio();
- const holding = portfolio.find(h => h.symbol === symbol);
-
- if (!holding) {
- return { success: false, error: `No holding for ${symbol}` };
- }
-
- result = await engine.placeOrder({
- symbol,
- side: holding.side === 'long' ? 'sell' : 'buy',
- type: 'market',
- qty: qty ?? holding.qty,
- timeInForce: 'day',
- });
- }
-
- return {
- success: result.success,
- error: result.error,
- order: result.success
- ? {
- id: result.orderId,
- status: result.filledPrice ? 'filled' : 'pending',
- filledPrice: result.filledPrice,
- filledQty: result.filledQty,
- }
- : undefined,
- };
- }
-
- case 'cancelOrder': {
- const orderId = op.params.orderId as string;
- const success = await engine.cancelOrder(orderId);
- return { success, error: success ? undefined : 'Failed to cancel order' };
- }
-
- default:
- throw new Error(`Unknown operation action: ${op.action}`);
- }
- };
-}
diff --git a/src/extension/securities-trading/providers/alpaca/AlpacaTradingEngine.ts b/src/extension/securities-trading/providers/alpaca/AlpacaTradingEngine.ts
deleted file mode 100644
index be692e61..00000000
--- a/src/extension/securities-trading/providers/alpaca/AlpacaTradingEngine.ts
+++ /dev/null
@@ -1,321 +0,0 @@
-/**
- * Alpaca Trading Engine
- *
- * Alpaca implementation of ISecuritiesTradingEngine
- * Uses @alpacahq/alpaca-trade-api SDK for US stock trading
- *
- * Alpaca REST API response format reference:
- * - Account: { cash, portfolio_value, equity, buying_power, ... }
- * - Position: { symbol, side, qty, avg_entry_price, current_price, market_value, unrealized_pl, ... }
- * - Order: { id, symbol, side, type, qty, limit_price, stop_price, time_in_force, status, filled_avg_price, filled_qty, ... }
- * - Clock: { is_open, next_open, next_close, timestamp }
- */
-
-import Alpaca from '@alpacahq/alpaca-trade-api';
-import type {
- ISecuritiesTradingEngine,
- SecOrderRequest,
- SecOrderResult,
- SecOrder,
- SecHolding,
- SecAccountInfo,
- SecQuote,
- MarketClock,
-} from '../../interfaces.js';
-
-export interface AlpacaTradingEngineConfig {
- apiKey: string;
- secretKey: string;
- paper: boolean;
-}
-
-// Alpaca SDK response shapes (SDK types are all `any`)
-interface AlpacaAccountRaw {
- cash: string;
- portfolio_value: string;
- equity: string;
- buying_power: string;
- long_market_value: string;
- short_market_value: string;
- daytrade_count: number;
- daytrading_buying_power: string;
-}
-
-interface AlpacaPositionRaw {
- symbol: string;
- side: string;
- qty: string;
- avg_entry_price: string;
- current_price: string;
- market_value: string;
- unrealized_pl: string;
- unrealized_plpc: string;
- cost_basis: string;
-}
-
-interface AlpacaOrderRaw {
- id: string;
- symbol: string;
- side: string;
- type: string;
- qty: string | null;
- notional: string | null;
- limit_price: string | null;
- stop_price: string | null;
- time_in_force: string;
- extended_hours: boolean;
- status: string;
- filled_avg_price: string | null;
- filled_qty: string | null;
- filled_at: string | null;
- created_at: string;
- reject_reason: string | null;
-}
-
-interface AlpacaSnapshotRaw {
- LatestTrade: { Price: number; Timestamp: string };
- LatestQuote: { BidPrice: number; AskPrice: number; Timestamp: string };
- DailyBar: { Volume: number };
-}
-
-interface AlpacaClockRaw {
- is_open: boolean;
- next_open: string;
- next_close: string;
- timestamp: string;
-}
-
-export class AlpacaTradingEngine implements ISecuritiesTradingEngine {
- private readonly config: AlpacaTradingEngineConfig;
- private client!: InstanceType;
-
- constructor(config: AlpacaTradingEngineConfig) {
- this.config = config;
- }
-
- async init(): Promise {
- this.client = new Alpaca({
- keyId: this.config.apiKey,
- secretKey: this.config.secretKey,
- paper: this.config.paper,
- });
-
- // Verify connection by fetching account
- const account = await this.client.getAccount() as AlpacaAccountRaw;
- console.log(
- `Alpaca: connected (paper=${this.config.paper}, equity=$${parseFloat(account.equity).toFixed(2)})`,
- );
- }
-
- async close(): Promise {
- // Alpaca SDK has no explicit close/disconnect
- }
-
- async placeOrder(order: SecOrderRequest): Promise {
- try {
- const alpacaOrder: Record = {
- symbol: order.symbol,
- side: order.side,
- type: order.type,
- time_in_force: order.timeInForce,
- };
-
- if (order.qty != null) {
- alpacaOrder.qty = order.qty;
- } else if (order.notional != null) {
- alpacaOrder.notional = order.notional;
- }
-
- if (order.price != null) {
- alpacaOrder.limit_price = order.price;
- }
- if (order.stopPrice != null) {
- alpacaOrder.stop_price = order.stopPrice;
- }
- if (order.extendedHours != null) {
- alpacaOrder.extended_hours = order.extendedHours;
- }
-
- const result = await this.client.createOrder(alpacaOrder) as AlpacaOrderRaw;
-
- const isFilled = result.status === 'filled';
- return {
- success: true,
- orderId: result.id,
- filledPrice: isFilled && result.filled_avg_price ? parseFloat(result.filled_avg_price) : undefined,
- filledQty: isFilled && result.filled_qty ? parseFloat(result.filled_qty) : undefined,
- };
- } catch (err) {
- return {
- success: false,
- error: err instanceof Error ? err.message : String(err),
- };
- }
- }
-
- async getPortfolio(): Promise {
- const positions = await this.client.getPositions() as AlpacaPositionRaw[];
-
- return positions.map(p => ({
- symbol: p.symbol,
- side: p.side === 'long' ? 'long' as const : 'short' as const,
- qty: parseFloat(p.qty),
- avgEntryPrice: parseFloat(p.avg_entry_price),
- currentPrice: parseFloat(p.current_price),
- marketValue: parseFloat(p.market_value),
- unrealizedPnL: parseFloat(p.unrealized_pl),
- unrealizedPnLPercent: parseFloat(p.unrealized_plpc) * 100,
- costBasis: parseFloat(p.cost_basis),
- }));
- }
-
- async getOrders(): Promise {
- const orders = await this.client.getOrders({
- status: 'all',
- limit: 100,
- until: undefined,
- after: undefined,
- direction: undefined,
- nested: undefined,
- symbols: undefined,
- }) as AlpacaOrderRaw[];
-
- return orders.map(o => this.mapOrder(o));
- }
-
- async getAccount(): Promise {
- const account = await this.client.getAccount() as AlpacaAccountRaw;
-
- // Calculate unrealized PnL from positions
- const positions = await this.client.getPositions() as AlpacaPositionRaw[];
- const unrealizedPnL = positions.reduce(
- (sum, p) => sum + parseFloat(p.unrealized_pl),
- 0,
- );
-
- return {
- cash: parseFloat(account.cash),
- portfolioValue: parseFloat(account.portfolio_value),
- equity: parseFloat(account.equity),
- buyingPower: parseFloat(account.buying_power),
- unrealizedPnL,
- realizedPnL: 0, // Alpaca account API 不提供此字段,由 wallet commit history 追踪
- dayTradeCount: account.daytrade_count,
- dayTradingBuyingPower: parseFloat(account.daytrading_buying_power),
- };
- }
-
- async cancelOrder(orderId: string): Promise {
- try {
- await this.client.cancelOrder(orderId);
- return true;
- } catch {
- return false;
- }
- }
-
- async getMarketClock(): Promise {
- const clock = await this.client.getClock() as AlpacaClockRaw;
-
- return {
- isOpen: clock.is_open,
- nextOpen: new Date(clock.next_open),
- nextClose: new Date(clock.next_close),
- timestamp: new Date(clock.timestamp),
- };
- }
-
- async getQuote(symbol: string): Promise {
- const snapshot = await this.client.getSnapshot(symbol) as AlpacaSnapshotRaw;
-
- return {
- symbol,
- last: snapshot.LatestTrade.Price,
- bid: snapshot.LatestQuote.BidPrice,
- ask: snapshot.LatestQuote.AskPrice,
- volume: snapshot.DailyBar.Volume,
- timestamp: new Date(snapshot.LatestTrade.Timestamp),
- };
- }
-
- async closePosition(symbol: string, qty?: number): Promise {
- // Alpaca SDK closePosition only supports full close.
- // For partial close, fall back to reverse market order.
- if (qty != null) {
- const portfolio = await this.getPortfolio();
- const holding = portfolio.find(h => h.symbol === symbol);
- if (!holding) {
- return { success: false, error: `No holding for ${symbol}` };
- }
- return this.placeOrder({
- symbol,
- side: holding.side === 'long' ? 'sell' : 'buy',
- type: 'market',
- qty,
- timeInForce: 'day',
- });
- }
-
- try {
- const result = await this.client.closePosition(symbol) as AlpacaOrderRaw;
- const isFilled = result.status === 'filled';
- return {
- success: true,
- orderId: result.id,
- filledPrice: isFilled && result.filled_avg_price ? parseFloat(result.filled_avg_price) : undefined,
- filledQty: isFilled && result.filled_qty ? parseFloat(result.filled_qty) : undefined,
- };
- } catch (err) {
- return {
- success: false,
- error: err instanceof Error ? err.message : String(err),
- };
- }
- }
-
- // ==================== Internal methods ====================
-
- private mapOrder(o: AlpacaOrderRaw): SecOrder {
- return {
- id: o.id,
- symbol: o.symbol,
- side: o.side as 'buy' | 'sell',
- type: o.type as SecOrder['type'],
- qty: parseFloat(o.qty ?? o.notional ?? '0'),
- price: o.limit_price ? parseFloat(o.limit_price) : undefined,
- stopPrice: o.stop_price ? parseFloat(o.stop_price) : undefined,
- timeInForce: o.time_in_force as SecOrder['timeInForce'],
- extendedHours: o.extended_hours,
- status: this.mapOrderStatus(o.status),
- filledPrice: o.filled_avg_price ? parseFloat(o.filled_avg_price) : undefined,
- filledQty: o.filled_qty ? parseFloat(o.filled_qty) : undefined,
- filledAt: o.filled_at ? new Date(o.filled_at) : undefined,
- createdAt: new Date(o.created_at),
- rejectReason: o.reject_reason ?? undefined,
- };
- }
-
- private mapOrderStatus(alpacaStatus: string): SecOrder['status'] {
- switch (alpacaStatus) {
- case 'filled':
- return 'filled';
- case 'new':
- case 'accepted':
- case 'pending_new':
- case 'accepted_for_bidding':
- return 'pending';
- case 'canceled':
- case 'expired':
- case 'replaced':
- return 'cancelled';
- case 'partially_filled':
- return 'partially_filled';
- case 'done_for_day':
- case 'suspended':
- case 'rejected':
- return 'rejected';
- default:
- return 'pending';
- }
- }
-}
diff --git a/src/extension/securities-trading/providers/alpaca/index.ts b/src/extension/securities-trading/providers/alpaca/index.ts
deleted file mode 100644
index 4a46b90f..00000000
--- a/src/extension/securities-trading/providers/alpaca/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export { AlpacaTradingEngine } from './AlpacaTradingEngine';
-export type { AlpacaTradingEngineConfig } from './AlpacaTradingEngine';
diff --git a/src/extension/securities-trading/wallet-state-bridge.ts b/src/extension/securities-trading/wallet-state-bridge.ts
deleted file mode 100644
index 5f194fba..00000000
--- a/src/extension/securities-trading/wallet-state-bridge.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-/**
- * Securities Wallet State Bridge
- *
- * Provider-agnostic: ISecuritiesTradingEngine -> WalletState assembly
- */
-
-import type { ISecuritiesTradingEngine } from './interfaces.js';
-import type { WalletState } from './wallet/types.js';
-
-export function createSecWalletStateBridge(engine: ISecuritiesTradingEngine) {
- return async (): Promise => {
- const [account, holdings, orders] = await Promise.all([
- engine.getAccount(),
- engine.getPortfolio(),
- engine.getOrders(),
- ]);
-
- return {
- cash: account.cash,
- equity: account.equity,
- portfolioValue: account.portfolioValue,
- unrealizedPnL: account.unrealizedPnL,
- realizedPnL: account.realizedPnL,
- holdings,
- pendingOrders: orders.filter(o => o.status === 'pending'),
- };
- };
-}
diff --git a/src/extension/securities-trading/wallet/SecWallet.spec.ts b/src/extension/securities-trading/wallet/SecWallet.spec.ts
deleted file mode 100644
index b9561e8d..00000000
--- a/src/extension/securities-trading/wallet/SecWallet.spec.ts
+++ /dev/null
@@ -1,419 +0,0 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { SecWallet } from './SecWallet.js';
-import type { SecWalletConfig } from './interfaces.js';
-import type { Operation, WalletState } from './types.js';
-
-// ==================== Mock Factory ====================
-
-function createMockConfig(overrides: Partial = {}): SecWalletConfig {
- return {
- executeOperation: vi.fn().mockResolvedValue({
- success: true,
- order: { id: 'sec-001', status: 'filled', filledPrice: 150.25, filledQty: 10 },
- }),
- getWalletState: vi.fn().mockResolvedValue({
- cash: 50000, equity: 50000, portfolioValue: 0,
- unrealizedPnL: 0, realizedPnL: 0, holdings: [], pendingOrders: [],
- } satisfies WalletState),
- onCommit: vi.fn(),
- ...overrides,
- };
-}
-
-function makeOp(overrides: Partial = {}): Operation {
- return {
- action: 'placeOrder',
- params: { symbol: 'AAPL', side: 'buy', type: 'market', qty: 10 },
- ...overrides,
- };
-}
-
-// ==================== Tests ====================
-
-describe('SecWallet', () => {
- let config: SecWalletConfig;
- let wallet: SecWallet;
-
- beforeEach(() => {
- config = createMockConfig();
- wallet = new SecWallet(config);
- });
-
- // ==================== add ====================
-
- describe('add', () => {
- it('stages an operation and returns AddResult', () => {
- const op = makeOp();
- const result = wallet.add(op);
-
- expect(result).toEqual({ staged: true, index: 0, operation: op });
- });
-
- it('increments index for each subsequent add', () => {
- expect(wallet.add(makeOp()).index).toBe(0);
- expect(wallet.add(makeOp()).index).toBe(1);
- });
- });
-
- // ==================== commit ====================
-
- describe('commit', () => {
- it('prepares a commit with hash and message', () => {
- wallet.add(makeOp());
- const result = wallet.commit('Buy AAPL');
-
- expect(result.prepared).toBe(true);
- expect(result.hash).toHaveLength(8);
- expect(result.message).toBe('Buy AAPL');
- expect(result.operationCount).toBe(1);
- });
-
- it('throws when staging area is empty', () => {
- expect(() => wallet.commit('empty')).toThrow('Nothing to commit: staging area is empty');
- });
- });
-
- // ==================== push ====================
-
- describe('push', () => {
- it('executes staged operations', async () => {
- const op = makeOp();
- wallet.add(op);
- wallet.commit('test');
- await wallet.push();
-
- expect(config.executeOperation).toHaveBeenCalledWith(op);
- });
-
- it('records commit and updates head', async () => {
- wallet.add(makeOp());
- const { hash } = wallet.commit('test');
- await wallet.push();
-
- expect(wallet.status().head).toBe(hash);
- expect(wallet.status().commitCount).toBe(1);
- });
-
- it('clears staging after push', async () => {
- wallet.add(makeOp());
- wallet.commit('test');
- await wallet.push();
-
- const s = wallet.status();
- expect(s.staged).toEqual([]);
- expect(s.pendingMessage).toBeNull();
- });
-
- it('categorizes results into filled, pending, rejected', async () => {
- const execResults = [
- { success: true, order: { id: 's1', status: 'filled', filledPrice: 150, filledQty: 10 } },
- { success: true, order: { id: 's2', status: 'pending' } },
- { success: false, error: 'Rejected by broker' },
- ];
- let idx = 0;
- config = createMockConfig({
- executeOperation: vi.fn().mockImplementation(() => Promise.resolve(execResults[idx++])),
- });
- wallet = new SecWallet(config);
-
- wallet.add(makeOp());
- wallet.add(makeOp({ params: { symbol: 'AAPL', side: 'buy', type: 'limit', qty: 10, price: 140 } }));
- wallet.add(makeOp({ params: { symbol: 'MSFT', side: 'buy', type: 'market', qty: 99999 } }));
- wallet.commit('batch');
- const result = await wallet.push();
-
- expect(result.filled).toHaveLength(1);
- expect(result.pending).toHaveLength(1);
- expect(result.rejected).toHaveLength(1);
- });
-
- it('handles executeOperation errors gracefully', async () => {
- config = createMockConfig({
- executeOperation: vi.fn().mockRejectedValue(new Error('Connection lost')),
- });
- wallet = new SecWallet(config);
-
- wallet.add(makeOp());
- wallet.commit('failing');
- const result = await wallet.push();
-
- expect(result.rejected).toHaveLength(1);
- expect(result.rejected[0].error).toBe('Connection lost');
- });
-
- it('throws on empty staging', async () => {
- await expect(wallet.push()).rejects.toThrow('Nothing to push: staging area is empty');
- });
-
- it('throws when commit not called', async () => {
- wallet.add(makeOp());
- await expect(wallet.push()).rejects.toThrow('Nothing to push: please commit first');
- });
- });
-
- // ==================== full cycle ====================
-
- describe('add -> commit -> push cycle', () => {
- it('full happy path', async () => {
- wallet.add(makeOp());
- wallet.commit('Buy 10 AAPL');
- const result = await wallet.push();
-
- expect(result.operationCount).toBe(1);
- expect(result.filled).toHaveLength(1);
- expect(result.filled[0].orderId).toBe('sec-001');
- expect(result.filled[0].filledPrice).toBe(150.25);
- });
-
- it('sequential pushes create chained commits', async () => {
- wallet.add(makeOp());
- wallet.commit('first');
- const r1 = await wallet.push();
-
- wallet.add(makeOp({ params: { symbol: 'MSFT', side: 'buy', type: 'market', qty: 5 } }));
- wallet.commit('second');
- const r2 = await wallet.push();
-
- const c2 = wallet.show(r2.hash);
- expect(c2?.parentHash).toBe(r1.hash);
- });
- });
-
- // ==================== log ====================
-
- describe('log', () => {
- it('returns commits in reverse chronological order', async () => {
- wallet.add(makeOp());
- wallet.commit('first');
- await wallet.push();
-
- wallet.add(makeOp());
- wallet.commit('second');
- await wallet.push();
-
- const entries = wallet.log();
- expect(entries).toHaveLength(2);
- expect(entries[0].message).toBe('second');
- expect(entries[1].message).toBe('first');
- });
-
- it('respects limit parameter', async () => {
- for (let i = 0; i < 5; i++) {
- wallet.add(makeOp());
- wallet.commit(`commit ${i}`);
- await wallet.push();
- }
-
- expect(wallet.log({ limit: 2 })).toHaveLength(2);
- });
-
- it('filters by symbol', async () => {
- wallet.add(makeOp({ params: { symbol: 'AAPL', side: 'buy', type: 'market', qty: 10 } }));
- wallet.commit('aapl');
- await wallet.push();
-
- wallet.add(makeOp({ params: { symbol: 'MSFT', side: 'buy', type: 'market', qty: 5 } }));
- wallet.commit('msft');
- await wallet.push();
-
- const entries = wallet.log({ symbol: 'MSFT' });
- expect(entries).toHaveLength(1);
- expect(entries[0].message).toBe('msft');
- });
-
- it('formats securities-specific operation summaries', async () => {
- wallet.add(makeOp());
- wallet.commit('buy aapl');
- await wallet.push();
-
- const entries = wallet.log();
- expect(entries[0].operations[0].change).toContain('buy');
- expect(entries[0].operations[0].change).toContain('10 shares');
- });
- });
-
- // ==================== show / status ====================
-
- describe('show', () => {
- it('returns commit by hash', async () => {
- wallet.add(makeOp());
- const { hash } = wallet.commit('test');
- await wallet.push();
-
- expect(wallet.show(hash)?.message).toBe('test');
- });
-
- it('returns null for unknown hash', () => {
- expect(wallet.show('deadbeef')).toBeNull();
- });
- });
-
- describe('status', () => {
- it('shows initial empty state', () => {
- const s = wallet.status();
- expect(s.staged).toEqual([]);
- expect(s.head).toBeNull();
- expect(s.commitCount).toBe(0);
- });
- });
-
- // ==================== sync ====================
-
- describe('sync', () => {
- it('creates sync commit with order updates', async () => {
- const state: WalletState = {
- cash: 50000, equity: 51500, portfolioValue: 1500,
- unrealizedPnL: 0, realizedPnL: 0, holdings: [], pendingOrders: [],
- };
-
- const result = await wallet.sync(
- [{ orderId: 'sec-001', symbol: 'AAPL', previousStatus: 'pending', currentStatus: 'filled', filledPrice: 150, filledQty: 10 }],
- state,
- );
-
- expect(result.updatedCount).toBe(1);
- expect(wallet.status().head).toBe(result.hash);
- expect(config.onCommit).toHaveBeenCalled();
- });
-
- it('returns early when updates is empty', async () => {
- const state: WalletState = {
- cash: 50000, equity: 50000, portfolioValue: 0,
- unrealizedPnL: 0, realizedPnL: 0, holdings: [], pendingOrders: [],
- };
-
- const result = await wallet.sync([], state);
-
- expect(result.updatedCount).toBe(0);
- expect(wallet.status().commitCount).toBe(0);
- });
- });
-
- // ==================== getPendingOrderIds ====================
-
- describe('getPendingOrderIds', () => {
- it('returns orders still in pending status', async () => {
- config = createMockConfig({
- executeOperation: vi.fn().mockResolvedValue({
- success: true,
- order: { id: 'sec-pending', status: 'pending' },
- }),
- });
- wallet = new SecWallet(config);
-
- wallet.add(makeOp());
- wallet.commit('limit buy');
- await wallet.push();
-
- expect(wallet.getPendingOrderIds()).toEqual([
- { orderId: 'sec-pending', symbol: 'AAPL' },
- ]);
- });
-
- it('excludes orders updated to filled by sync', async () => {
- config = createMockConfig({
- executeOperation: vi.fn().mockResolvedValue({
- success: true,
- order: { id: 'sec-200', status: 'pending' },
- }),
- });
- wallet = new SecWallet(config);
-
- wallet.add(makeOp());
- wallet.commit('limit');
- await wallet.push();
-
- const state: WalletState = {
- cash: 50000, equity: 50000, portfolioValue: 0,
- unrealizedPnL: 0, realizedPnL: 0, holdings: [], pendingOrders: [],
- };
- await wallet.sync(
- [{ orderId: 'sec-200', symbol: 'AAPL', previousStatus: 'pending', currentStatus: 'filled', filledPrice: 150, filledQty: 10 }],
- state,
- );
-
- expect(wallet.getPendingOrderIds()).toEqual([]);
- });
- });
-
- // ==================== exportState / restore ====================
-
- describe('exportState / restore', () => {
- it('round-trips through export and restore', async () => {
- wallet.add(makeOp());
- wallet.commit('original');
- await wallet.push();
-
- const exported = wallet.exportState();
- const restored = SecWallet.restore(exported, config);
-
- expect(restored.status().head).toBe(wallet.status().head);
- expect(restored.status().commitCount).toBe(1);
- });
- });
-
- // ==================== simulatePriceChange ====================
-
- describe('simulatePriceChange', () => {
- it('returns no-change result when no holdings', async () => {
- const result = await wallet.simulatePriceChange([
- { symbol: 'AAPL', change: '+10%' },
- ]);
-
- expect(result.success).toBe(true);
- expect(result.summary.totalPnLChange).toBe(0);
- expect(result.summary.worstCase).toBe('No holdings to simulate.');
- });
-
- it('calculates PnL for long holding with relative change', async () => {
- config = createMockConfig({
- getWalletState: vi.fn().mockResolvedValue({
- cash: 48500, equity: 50000, portfolioValue: 1500,
- unrealizedPnL: 1000, realizedPnL: 0,
- holdings: [{
- symbol: 'AAPL', side: 'long', qty: 100,
- avgEntryPrice: 140, currentPrice: 150, marketValue: 15000,
- unrealizedPnL: 1000, unrealizedPnLPercent: 7.14, costBasis: 14000,
- }],
- pendingOrders: [],
- } satisfies WalletState),
- });
- wallet = new SecWallet(config);
-
- const result = await wallet.simulatePriceChange([
- { symbol: 'AAPL', change: '-10%' },
- ]);
-
- expect(result.success).toBe(true);
- // New price: 150 * 0.9 = 135
- // New PnL: (135 - 140) * 100 = -500
- // PnL change: -500 - 1000 = -1500
- expect(result.simulatedState.holdings[0].simulatedPrice).toBeCloseTo(135);
- expect(result.simulatedState.holdings[0].unrealizedPnL).toBeCloseTo(-500);
- expect(result.summary.totalPnLChange).toBeCloseTo(-1500);
- });
-
- it('returns error for invalid change format', async () => {
- config = createMockConfig({
- getWalletState: vi.fn().mockResolvedValue({
- cash: 48500, equity: 50000, portfolioValue: 1500,
- unrealizedPnL: 0, realizedPnL: 0,
- holdings: [{
- symbol: 'AAPL', side: 'long', qty: 100,
- avgEntryPrice: 140, currentPrice: 150, marketValue: 15000,
- unrealizedPnL: 1000, unrealizedPnLPercent: 7.14, costBasis: 14000,
- }],
- pendingOrders: [],
- } satisfies WalletState),
- });
- wallet = new SecWallet(config);
-
- const result = await wallet.simulatePriceChange([
- { symbol: 'AAPL', change: 'bad' },
- ]);
-
- expect(result.success).toBe(false);
- expect(result.error).toContain('Invalid change format');
- });
- });
-});
diff --git a/src/extension/securities-trading/wallet/SecWallet.ts b/src/extension/securities-trading/wallet/SecWallet.ts
deleted file mode 100644
index 6a494ad6..00000000
--- a/src/extension/securities-trading/wallet/SecWallet.ts
+++ /dev/null
@@ -1,532 +0,0 @@
-/**
- * Securities Wallet implementation
- *
- * Git-like state management, tracking securities trading operation history
- */
-
-import { createHash } from 'crypto';
-import type { ISecWallet, SecWalletConfig } from './interfaces';
-import type {
- CommitHash,
- Operation,
- OperationResult,
- AddResult,
- CommitPrepareResult,
- PushResult,
- WalletStatus,
- WalletCommit,
- WalletState,
- CommitLogEntry,
- WalletExportState,
- OperationSummary,
- PriceChangeInput,
- SimulatePriceChangeResult,
- OrderStatusUpdate,
- SyncResult,
-} from './types';
-
-function generateCommitHash(content: object): CommitHash {
- const hash = createHash('sha256')
- .update(JSON.stringify(content))
- .digest('hex');
- return hash.slice(0, 8);
-}
-
-export class SecWallet implements ISecWallet {
- private stagingArea: Operation[] = [];
- private pendingMessage: string | null = null;
- private pendingHash: CommitHash | null = null;
- private commits: WalletCommit[] = [];
- private head: CommitHash | null = null;
- private currentRound: number | undefined = undefined;
- private readonly config: SecWalletConfig;
-
- constructor(config: SecWalletConfig) {
- this.config = config;
- }
-
- // ==================== Git-style three-phase workflow ====================
-
- add(operation: Operation): AddResult {
- this.stagingArea.push(operation);
- return {
- staged: true,
- index: this.stagingArea.length - 1,
- operation,
- };
- }
-
- commit(message: string): CommitPrepareResult {
- if (this.stagingArea.length === 0) {
- throw new Error('Nothing to commit: staging area is empty');
- }
-
- const timestamp = new Date().toISOString();
- this.pendingHash = generateCommitHash({
- message,
- operations: this.stagingArea,
- timestamp,
- parentHash: this.head,
- });
- this.pendingMessage = message;
-
- return {
- prepared: true,
- hash: this.pendingHash,
- message,
- operationCount: this.stagingArea.length,
- };
- }
-
- async push(): Promise {
- if (this.stagingArea.length === 0) {
- throw new Error('Nothing to push: staging area is empty');
- }
-
- if (this.pendingMessage === null || this.pendingHash === null) {
- throw new Error('Nothing to push: please commit first');
- }
-
- const operations = [...this.stagingArea];
- const message = this.pendingMessage;
- const hash = this.pendingHash;
-
- const results: OperationResult[] = [];
- for (const op of operations) {
- try {
- const raw = await this.config.executeOperation(op);
- const result = this.parseOperationResult(op, raw);
- results.push(result);
- } catch (error) {
- results.push({
- action: op.action,
- success: false,
- status: 'rejected',
- error: error instanceof Error ? error.message : String(error),
- });
- }
- }
-
- const stateAfter = await this.config.getWalletState();
-
- const commit: WalletCommit = {
- hash,
- parentHash: this.head,
- message,
- operations,
- results,
- stateAfter,
- timestamp: new Date().toISOString(),
- round: this.currentRound,
- };
-
- this.commits.push(commit);
- this.head = hash;
-
- await this.config.onCommit?.(this.exportState());
-
- this.stagingArea = [];
- this.pendingMessage = null;
- this.pendingHash = null;
-
- const filled = results.filter((r) => r.status === 'filled');
- const pending = results.filter((r) => r.status === 'pending');
- const rejected = results.filter(
- (r) => r.status === 'rejected' || !r.success,
- );
-
- return { hash, message, operationCount: operations.length, filled, pending, rejected };
- }
-
- // ==================== Queries ====================
-
- log(options: { limit?: number; symbol?: string } = {}): CommitLogEntry[] {
- const { limit = 10, symbol } = options;
-
- let commits = this.commits.slice().reverse();
-
- if (symbol) {
- commits = commits.filter((commit) =>
- commit.operations.some((op) => op.params.symbol === symbol),
- );
- }
-
- commits = commits.slice(0, limit);
-
- return commits.map((commit) => ({
- hash: commit.hash,
- parentHash: commit.parentHash,
- message: commit.message,
- timestamp: commit.timestamp,
- round: commit.round,
- operations: this.buildOperationSummaries(commit, symbol),
- }));
- }
-
- private buildOperationSummaries(
- commit: WalletCommit,
- filterSymbol?: string,
- ): OperationSummary[] {
- const summaries: OperationSummary[] = [];
-
- for (let i = 0; i < commit.operations.length; i++) {
- const op = commit.operations[i];
- const result = commit.results[i];
- const symbol = (op.params.symbol as string) || 'unknown';
-
- if (filterSymbol && symbol !== filterSymbol) {
- continue;
- }
-
- const change = this.formatOperationChange(op, result);
- summaries.push({ symbol, action: op.action, change, status: result?.status || 'rejected' });
- }
-
- return summaries;
- }
-
- private formatOperationChange(op: Operation, result?: OperationResult): string {
- const { action, params } = op;
-
- switch (action) {
- case 'placeOrder': {
- const side = params.side as string;
- const notional = params.notional as number | undefined;
- const qty = params.qty as number | undefined;
- const sizeStr = notional ? `$${notional}` : `${qty} shares`;
-
- if (result?.status === 'filled') {
- const price = result.filledPrice ? ` @$${result.filledPrice}` : '';
- return `${side} ${sizeStr}${price}`;
- }
- return `${side} ${sizeStr} (${result?.status || 'unknown'})`;
- }
-
- case 'closePosition': {
- const qty = params.qty as number | undefined;
- if (result?.status === 'filled') {
- const price = result.filledPrice ? ` @$${result.filledPrice}` : '';
- const qtyStr = qty ? ` (partial: ${qty})` : '';
- return `sold${qtyStr}${price}`;
- }
- return `sell (${result?.status || 'unknown'})`;
- }
-
- case 'cancelOrder': {
- return `cancelled order ${params.orderId}`;
- }
-
- case 'syncOrders': {
- const status = result?.status || 'unknown';
- const price = result?.filledPrice ? ` @$${result.filledPrice}` : '';
- return `synced → ${status}${price}`;
- }
-
- default:
- return `${action}`;
- }
- }
-
- show(hash: CommitHash): WalletCommit | null {
- return this.commits.find((c) => c.hash === hash) ?? null;
- }
-
- status(): WalletStatus {
- return {
- staged: [...this.stagingArea],
- pendingMessage: this.pendingMessage,
- head: this.head,
- commitCount: this.commits.length,
- };
- }
-
- // ==================== Serialization ====================
-
- exportState(): WalletExportState {
- return { commits: [...this.commits], head: this.head };
- }
-
- static restore(state: WalletExportState, config: SecWalletConfig): SecWallet {
- const wallet = new SecWallet(config);
- wallet.commits = [...state.commits];
- wallet.head = state.head;
- return wallet;
- }
-
- setCurrentRound(round: number): void {
- this.currentRound = round;
- }
-
- // ==================== Sync ====================
-
- async sync(updates: OrderStatusUpdate[], currentState: WalletState): Promise {
- if (updates.length === 0) {
- return { hash: this.head ?? '', updatedCount: 0, updates: [] };
- }
-
- const hash = generateCommitHash({
- updates,
- timestamp: new Date().toISOString(),
- parentHash: this.head,
- });
-
- const commit: WalletCommit = {
- hash,
- parentHash: this.head,
- message: `[sync] ${updates.length} order(s) updated`,
- operations: [{ action: 'syncOrders', params: { orderIds: updates.map(u => u.orderId) } }],
- results: updates.map(u => ({
- action: 'syncOrders' as const,
- success: true,
- orderId: u.orderId,
- status: u.currentStatus,
- filledPrice: u.filledPrice,
- filledQty: u.filledQty,
- })),
- stateAfter: currentState,
- timestamp: new Date().toISOString(),
- round: this.currentRound,
- };
-
- this.commits.push(commit);
- this.head = hash;
-
- await this.config.onCommit?.(this.exportState());
-
- return { hash, updatedCount: updates.length, updates };
- }
-
- getPendingOrderIds(): Array<{ orderId: string; symbol: string }> {
- const orderStatus = new Map();
-
- for (let i = this.commits.length - 1; i >= 0; i--) {
- for (const result of this.commits[i].results) {
- if (result.orderId && !orderStatus.has(result.orderId)) {
- orderStatus.set(result.orderId, result.status);
- }
- }
- }
-
- const pending: Array<{ orderId: string; symbol: string }> = [];
- const seen = new Set();
-
- for (const commit of this.commits) {
- for (let j = 0; j < commit.results.length; j++) {
- const result = commit.results[j];
- if (
- result.orderId &&
- !seen.has(result.orderId) &&
- orderStatus.get(result.orderId) === 'pending'
- ) {
- const symbol = (commit.operations[j]?.params?.symbol as string) ?? 'unknown';
- pending.push({ orderId: result.orderId, symbol });
- seen.add(result.orderId);
- }
- }
- }
-
- return pending;
- }
-
- // ==================== Simulation ====================
-
- async simulatePriceChange(
- priceChanges: PriceChangeInput[],
- ): Promise {
- const state = await this.config.getWalletState();
- const { holdings, equity, unrealizedPnL, cash } = state;
-
- const currentTotalPnL =
- cash > 0 ? ((equity - cash) / cash) * 100 : 0;
-
- if (holdings.length === 0) {
- return {
- success: true,
- currentState: { equity, unrealizedPnL, totalPnL: currentTotalPnL, holdings: [] },
- simulatedState: { equity, unrealizedPnL, totalPnL: currentTotalPnL, holdings: [] },
- summary: {
- totalPnLChange: 0,
- equityChange: 0,
- equityChangePercent: '0.0%',
- worstCase: 'No holdings to simulate.',
- },
- };
- }
-
- const priceMap = new Map();
-
- for (const { symbol, change } of priceChanges) {
- const parsed = this.parsePriceChange(change);
- if (!parsed.success) {
- return {
- success: false,
- error: `Invalid change format for ${symbol}: "${change}". Use "@150" for absolute or "+10%" / "-5%" for relative.`,
- currentState: { equity, unrealizedPnL, totalPnL: currentTotalPnL, holdings: [] },
- simulatedState: { equity, unrealizedPnL, totalPnL: currentTotalPnL, holdings: [] },
- summary: { totalPnLChange: 0, equityChange: 0, equityChangePercent: '0.0%', worstCase: '' },
- };
- }
-
- if (symbol === 'all') {
- for (const h of holdings) {
- priceMap.set(h.symbol, this.applyPriceChange(h.currentPrice, parsed.type, parsed.value));
- }
- } else {
- const h = holdings.find((p) => p.symbol === symbol);
- if (h) {
- priceMap.set(symbol, this.applyPriceChange(h.currentPrice, parsed.type, parsed.value));
- }
- }
- }
-
- const currentHoldings = holdings.map((h) => ({
- symbol: h.symbol,
- side: h.side,
- qty: h.qty,
- avgEntryPrice: h.avgEntryPrice,
- currentPrice: h.currentPrice,
- unrealizedPnL: h.unrealizedPnL,
- marketValue: h.marketValue,
- }));
-
- let simulatedUnrealizedPnL = 0;
- const simulatedHoldings = holdings.map((h) => {
- const simulatedPrice = priceMap.get(h.symbol) ?? h.currentPrice;
- const priceChange = simulatedPrice - h.currentPrice;
- const priceChangePercent =
- h.currentPrice > 0 ? (priceChange / h.currentPrice) * 100 : 0;
-
- const newUnrealizedPnL =
- h.side === 'long'
- ? (simulatedPrice - h.avgEntryPrice) * h.qty
- : (h.avgEntryPrice - simulatedPrice) * h.qty;
-
- const pnlChange = newUnrealizedPnL - h.unrealizedPnL;
- simulatedUnrealizedPnL += newUnrealizedPnL;
-
- return {
- symbol: h.symbol,
- side: h.side,
- qty: h.qty,
- avgEntryPrice: h.avgEntryPrice,
- simulatedPrice,
- unrealizedPnL: newUnrealizedPnL,
- marketValue: simulatedPrice * h.qty,
- pnlChange,
- priceChangePercent: `${priceChangePercent >= 0 ? '+' : ''}${priceChangePercent.toFixed(2)}%`,
- };
- });
-
- const pnlDiff = simulatedUnrealizedPnL - unrealizedPnL;
- const simulatedEquity = equity + pnlDiff;
- const simulatedTotalPnL =
- cash > 0 ? ((simulatedEquity - cash) / cash) * 100 : 0;
- const equityChangePercent = equity > 0 ? (pnlDiff / equity) * 100 : 0;
-
- const worstHolding = simulatedHoldings.reduce(
- (worst, h) => (h.pnlChange < worst.pnlChange ? h : worst),
- simulatedHoldings[0],
- );
-
- const worstCase =
- worstHolding.pnlChange < 0
- ? `${worstHolding.symbol} would lose $${Math.abs(worstHolding.pnlChange).toFixed(2)} (${worstHolding.priceChangePercent})`
- : 'All holdings would profit or break even.';
-
- return {
- success: true,
- currentState: { equity, unrealizedPnL, totalPnL: currentTotalPnL, holdings: currentHoldings },
- simulatedState: {
- equity: simulatedEquity,
- unrealizedPnL: simulatedUnrealizedPnL,
- totalPnL: simulatedTotalPnL,
- holdings: simulatedHoldings,
- },
- summary: {
- totalPnLChange: pnlDiff,
- equityChange: pnlDiff,
- equityChangePercent: `${equityChangePercent >= 0 ? '+' : ''}${equityChangePercent.toFixed(2)}%`,
- worstCase,
- },
- };
- }
-
- private parsePriceChange(
- change: string,
- ):
- | { success: true; type: 'absolute' | 'relative'; value: number }
- | { success: false } {
- const trimmed = change.trim();
-
- if (trimmed.startsWith('@')) {
- const value = parseFloat(trimmed.slice(1));
- if (isNaN(value) || value <= 0) return { success: false };
- return { success: true, type: 'absolute', value };
- }
-
- if (trimmed.endsWith('%')) {
- const value = parseFloat(trimmed.slice(0, -1));
- if (isNaN(value)) return { success: false };
- return { success: true, type: 'relative', value };
- }
-
- return { success: false };
- }
-
- private applyPriceChange(
- currentPrice: number,
- type: 'absolute' | 'relative',
- value: number,
- ): number {
- return type === 'absolute' ? value : currentPrice * (1 + value / 100);
- }
-
- // ==================== Internal methods ====================
-
- private parseOperationResult(op: Operation, raw: unknown): OperationResult {
- const rawObj = raw as Record;
-
- if (!rawObj || typeof rawObj !== 'object') {
- return {
- action: op.action,
- success: false,
- status: 'rejected',
- error: 'Invalid response from trading engine',
- raw,
- };
- }
-
- const success = rawObj.success === true;
- const order = rawObj.order as Record | undefined;
-
- if (!success) {
- return {
- action: op.action,
- success: false,
- status: 'rejected',
- error: (rawObj.error as string) ?? 'Unknown error',
- raw,
- };
- }
-
- if (!order) {
- return { action: op.action, success: true, status: 'filled', raw };
- }
-
- const status = order.status as string;
- const isFilled = status === 'filled';
- const isPending = status === 'pending';
-
- return {
- action: op.action,
- success: true,
- orderId: order.id as string | undefined,
- status: isFilled ? 'filled' : isPending ? 'pending' : 'rejected',
- filledPrice: isFilled ? (order.filledPrice as number) : undefined,
- filledQty: isFilled
- ? ((order.filledQty ?? order.qty) as number)
- : undefined,
- raw,
- };
- }
-}
diff --git a/src/extension/securities-trading/wallet/adapter.ts b/src/extension/securities-trading/wallet/adapter.ts
deleted file mode 100644
index 2a3b49d8..00000000
--- a/src/extension/securities-trading/wallet/adapter.ts
+++ /dev/null
@@ -1,167 +0,0 @@
-import { tool } from 'ai';
-import { z } from 'zod';
-import type { ISecWallet } from './interfaces';
-
-/**
- * Create securities wallet AI tools (decision management)
- *
- * Git-like operations for tracking and reviewing securities trading decisions:
- * - secWalletCommit/secWalletPush: Record decisions with explanations
- * - secWalletLog/secWalletShow/secWalletStatus: Review decision history
- * - secSimulatePriceChange: Dry-run impact analysis
- */
-export function createSecWalletToolsImpl(wallet: ISecWallet) {
- return {
- secWalletCommit: tool({
- description: `
-Commit staged securities trading operations with a message (like "git commit -m").
-
-After staging operations with secPlaceOrder/secClosePosition/etc., use this to:
-1. Add a commit message explaining WHY you're making these trades
-2. Prepare the operations for execution
-
-This does NOT execute the trades yet - call secWalletPush after this.
-
-Example workflow:
-1. secPlaceOrder({ symbol: "AAPL", side: "buy", ... }) → staged
-2. secWalletCommit({ message: "Buying AAPL on strong earnings beat" })
-3. secWalletPush() → executes and records
- `.trim(),
- inputSchema: z.object({
- message: z
- .string()
- .describe('Commit message explaining your trading decision'),
- }),
- execute: ({ message }) => {
- return wallet.commit(message);
- },
- }),
-
- secWalletPush: tool({
- description: `
-Execute all committed securities trading operations (like "git push").
-
-After staging operations and committing them, use this to:
-1. Execute all staged operations against the securities broker
-2. Record the commit with results to wallet history
-
-Returns execution results for each operation (filled/pending/rejected).
-
-IMPORTANT: You must call secWalletCommit first before pushing.
- `.trim(),
- inputSchema: z.object({}),
- execute: async () => {
- return await wallet.push();
- },
- }),
-
- secWalletLog: tool({
- description: `
-View your securities trading decision history (like "git log --stat").
-
-IMPORTANT: Check this BEFORE making new trading decisions to:
-- Review what you planned in recent commits
-- Avoid contradicting your own strategy
-- Maintain consistency across rounds
-
-Returns recent trading commits in reverse chronological order (newest first).
-Each commit includes:
-- hash: Unique commit identifier
-- message: Your explanation for the trades
-- operations: Summary of each operation (symbol, action, change, status)
-- timestamp: When the commit was made
-
-Use symbol parameter to filter commits for a specific ticker.
-Use secWalletShow(hash) for full details of a specific commit.
- `.trim(),
- inputSchema: z.object({
- limit: z
- .number()
- .int()
- .positive()
- .optional()
- .describe('Number of recent commits to return (default: 10)'),
- symbol: z
- .string()
- .optional()
- .describe('Filter commits by symbol (e.g., "AAPL"). Only shows commits that affected this symbol.'),
- }),
- execute: ({ limit, symbol }) => {
- return wallet.log({ limit, symbol });
- },
- }),
-
- secWalletShow: tool({
- description: `
-View details of a specific securities wallet commit (like "git show ").
-
-Returns full commit information including:
-- All operations that were executed
-- Results of each operation (filled price, qty, errors)
-- Wallet state after the commit (holdings, cash)
-
-Use this to inspect what happened in a specific trading commit.
- `.trim(),
- inputSchema: z.object({
- hash: z.string().describe('Commit hash to inspect (8 characters)'),
- }),
- execute: ({ hash }) => {
- const commit = wallet.show(hash);
- if (!commit) return { error: `Commit ${hash} not found` };
- return commit;
- },
- }),
-
- secWalletStatus: tool({
- description: `
-View current securities wallet staging area status (like "git status").
-
-Returns:
-- staged: List of operations waiting to be committed/pushed
-- pendingMessage: Commit message if already committed but not pushed
-- head: Hash of the latest commit
-- commitCount: Total number of commits in history
-
-Use this to check if you have pending operations before making more trades.
- `.trim(),
- inputSchema: z.object({}),
- execute: () => {
- return wallet.status();
- },
- }),
-
- secSimulatePriceChange: tool({
- description: `
-Simulate price changes to see securities portfolio impact BEFORE making decisions (dry run).
-
-Use this tool to:
-- See how much you would lose if a stock drops
-- Understand the impact of market movements on your portfolio
-- Make informed decisions about position sizing
-
-Price change syntax:
-- Absolute: "@150" means price becomes $150
-- Relative: "+10%" means price increases by 10%, "-5%" means price decreases by 5%
-
-You can simulate changes for:
-- A specific symbol: { symbol: "AAPL", change: "@150" }
-- All holdings: { symbol: "all", change: "-10%" }
-
-IMPORTANT: This is READ-ONLY - it does NOT modify your actual holdings.
- `.trim(),
- inputSchema: z.object({
- priceChanges: z
- .array(
- z.object({
- symbol: z.string().describe('Ticker (e.g., "AAPL") or "all" for all holdings'),
- change: z.string().describe('Price change: "@150" for absolute, "+10%" or "-5%" for relative'),
- }),
- )
- .describe('Array of price changes to simulate'),
- }),
- execute: async ({ priceChanges }) => {
- return await wallet.simulatePriceChange(priceChanges);
- },
- }),
- };
-}
diff --git a/src/extension/securities-trading/wallet/interfaces.ts b/src/extension/securities-trading/wallet/interfaces.ts
deleted file mode 100644
index 2f822307..00000000
--- a/src/extension/securities-trading/wallet/interfaces.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-/**
- * Securities Wallet interface definitions
- *
- * Git-like state management interfaces for securities trading
- */
-
-import type {
- CommitHash,
- Operation,
- AddResult,
- CommitPrepareResult,
- PushResult,
- WalletStatus,
- WalletCommit,
- CommitLogEntry,
- WalletExportState,
- PriceChangeInput,
- SimulatePriceChangeResult,
- OrderStatusUpdate,
- SyncResult,
- WalletState,
-} from './types';
-
-export interface ISecWallet {
- // ==================== Git-style three-phase workflow ====================
-
- add(operation: Operation): AddResult;
- commit(message: string): CommitPrepareResult;
- push(): Promise;
-
- // ==================== Queries ====================
-
- log(options?: { limit?: number; symbol?: string }): CommitLogEntry[];
- show(hash: CommitHash): WalletCommit | null;
- status(): WalletStatus;
-
- // ==================== Sync ====================
-
- sync(updates: OrderStatusUpdate[], currentState: WalletState): Promise;
- getPendingOrderIds(): Array<{ orderId: string; symbol: string }>;
-
- // ==================== Serialization ====================
-
- exportState(): WalletExportState;
- setCurrentRound(round: number): void;
-
- // ==================== Simulation ====================
-
- simulatePriceChange(
- priceChanges: PriceChangeInput[],
- ): Promise;
-}
-
-export interface SecWalletConfig {
- executeOperation: (operation: Operation) => Promise;
- getWalletState: () => Promise;
- onCommit?: (state: WalletExportState) => void | Promise;
-}
diff --git a/src/extension/securities-trading/wallet/types.ts b/src/extension/securities-trading/wallet/types.ts
deleted file mode 100644
index affdf186..00000000
--- a/src/extension/securities-trading/wallet/types.ts
+++ /dev/null
@@ -1,187 +0,0 @@
-/**
- * Securities Wallet type definitions
- *
- * Git-like wallet state management for tracking securities trading operation history
- */
-
-import type { SecHolding, SecOrder } from '../interfaces';
-
-// ==================== Commit Hash ====================
-
-export type CommitHash = string;
-
-// ==================== Operation ====================
-
-export type OperationAction =
- | 'placeOrder'
- | 'closePosition'
- | 'cancelOrder'
- | 'syncOrders';
-
-export interface Operation {
- action: OperationAction;
- params: Record;
-}
-
-// ==================== Operation Result ====================
-
-export type OperationStatus = 'filled' | 'pending' | 'rejected' | 'cancelled' | 'partially_filled';
-
-export interface OperationResult {
- action: OperationAction;
- success: boolean;
- orderId?: string;
- status: OperationStatus;
- filledPrice?: number;
- filledQty?: number;
- error?: string;
- raw?: unknown;
-}
-
-// ==================== Wallet State ====================
-
-export interface WalletState {
- cash: number;
- equity: number;
- portfolioValue: number;
- unrealizedPnL: number;
- realizedPnL: number;
- holdings: SecHolding[];
- pendingOrders: SecOrder[];
-}
-
-// ==================== Wallet Commit ====================
-
-export interface WalletCommit {
- hash: CommitHash;
- parentHash: CommitHash | null;
- message: string;
- operations: Operation[];
- results: OperationResult[];
- stateAfter: WalletState;
- timestamp: string;
- round?: number;
-}
-
-// ==================== API Results ====================
-
-export interface AddResult {
- staged: true;
- index: number;
- operation: Operation;
-}
-
-export interface CommitPrepareResult {
- prepared: true;
- hash: CommitHash;
- message: string;
- operationCount: number;
-}
-
-export interface PushResult {
- hash: CommitHash;
- message: string;
- operationCount: number;
- filled: OperationResult[];
- pending: OperationResult[];
- rejected: OperationResult[];
-}
-
-export interface WalletStatus {
- staged: Operation[];
- pendingMessage: string | null;
- head: CommitHash | null;
- commitCount: number;
-}
-
-export interface OperationSummary {
- symbol: string;
- action: OperationAction;
- change: string;
- status: OperationStatus;
-}
-
-export interface CommitLogEntry {
- hash: CommitHash;
- parentHash: CommitHash | null;
- message: string;
- timestamp: string;
- round?: number;
- operations: OperationSummary[];
-}
-
-// ==================== Export State ====================
-
-export interface WalletExportState {
- commits: WalletCommit[];
- head: CommitHash | null;
-}
-
-// ==================== Sync ====================
-
-export interface OrderStatusUpdate {
- orderId: string;
- symbol: string;
- previousStatus: OperationStatus;
- currentStatus: OperationStatus;
- filledPrice?: number;
- filledQty?: number;
-}
-
-export interface SyncResult {
- hash: CommitHash;
- updatedCount: number;
- updates: OrderStatusUpdate[];
-}
-
-// ==================== Simulate Price Change ====================
-
-export interface PriceChangeInput {
- symbol: string;
- change: string;
-}
-
-export interface SimulationHoldingCurrent {
- symbol: string;
- side: 'long' | 'short';
- qty: number;
- avgEntryPrice: number;
- currentPrice: number;
- unrealizedPnL: number;
- marketValue: number;
-}
-
-export interface SimulationHoldingAfter {
- symbol: string;
- side: 'long' | 'short';
- qty: number;
- avgEntryPrice: number;
- simulatedPrice: number;
- unrealizedPnL: number;
- marketValue: number;
- pnlChange: number;
- priceChangePercent: string;
-}
-
-export interface SimulatePriceChangeResult {
- success: boolean;
- error?: string;
- currentState: {
- equity: number;
- unrealizedPnL: number;
- totalPnL: number;
- holdings: SimulationHoldingCurrent[];
- };
- simulatedState: {
- equity: number;
- unrealizedPnL: number;
- totalPnL: number;
- holdings: SimulationHoldingAfter[];
- };
- summary: {
- totalPnLChange: number;
- equityChange: number;
- equityChangePercent: string;
- worstCase: string;
- };
-}
diff --git a/src/extension/trading/__test__/mock-account.ts b/src/extension/trading/__test__/mock-account.ts
new file mode 100644
index 00000000..e8a69083
--- /dev/null
+++ b/src/extension/trading/__test__/mock-account.ts
@@ -0,0 +1,188 @@
+/**
+ * Mock ITradingAccount for testing.
+ *
+ * All methods are vi.fn() so callers can override return values or inspect calls.
+ */
+
+import { vi } from 'vitest'
+import type { Contract, ContractDescription, ContractDetails } from '../contract.js'
+import type {
+ ITradingAccount,
+ AccountCapabilities,
+ AccountInfo,
+ Position,
+ Order,
+ OrderRequest,
+ OrderResult,
+ Quote,
+ MarketClock,
+} from '../interfaces.js'
+
+// ==================== Defaults ====================
+
+export const DEFAULT_ACCOUNT_INFO: AccountInfo = {
+ cash: 100_000,
+ equity: 105_000,
+ unrealizedPnL: 5_000,
+ realizedPnL: 1_000,
+ portfolioValue: 105_000,
+ buyingPower: 200_000,
+}
+
+export const DEFAULT_CAPABILITIES: AccountCapabilities = {
+ supportedSecTypes: ['STK'],
+ supportedOrderTypes: ['market', 'limit', 'stop', 'stop_limit'],
+}
+
+export function makeContract(overrides: Partial = {}): Contract {
+ return {
+ aliceId: 'mock-AAPL',
+ symbol: 'AAPL',
+ secType: 'STK',
+ exchange: 'NASDAQ',
+ currency: 'USD',
+ ...overrides,
+ }
+}
+
+export function makePosition(overrides: Partial = {}): Position {
+ const contract = makeContract(overrides.contract)
+ return {
+ contract,
+ side: 'long',
+ qty: 10,
+ avgEntryPrice: 150,
+ currentPrice: 160,
+ marketValue: 1600,
+ unrealizedPnL: 100,
+ unrealizedPnLPercent: 6.67,
+ costBasis: 1500,
+ leverage: 1,
+ ...overrides,
+ // Ensure nested contract override works
+ ...(overrides.contract ? { contract: { ...contract, ...overrides.contract } } : {}),
+ }
+}
+
+export function makeOrder(overrides: Partial = {}): Order {
+ return {
+ id: 'order-1',
+ contract: makeContract(),
+ side: 'buy',
+ type: 'market',
+ qty: 10,
+ status: 'filled',
+ filledPrice: 150,
+ filledQty: 10,
+ createdAt: new Date('2025-01-01'),
+ ...overrides,
+ }
+}
+
+export function makeOrderResult(overrides: Partial = {}): OrderResult {
+ return {
+ success: true,
+ orderId: 'order-1',
+ filledPrice: 150,
+ filledQty: 10,
+ ...overrides,
+ }
+}
+
+// ==================== MockTradingAccount ====================
+
+export interface MockTradingAccountOptions {
+ id?: string
+ provider?: string
+ label?: string
+ capabilities?: Partial
+ positions?: Position[]
+ orders?: Order[]
+ accountInfo?: Partial
+}
+
+export class MockTradingAccount implements ITradingAccount {
+ readonly id: string
+ readonly provider: string
+ readonly label: string
+
+ private _capabilities: AccountCapabilities
+ private _positions: Position[]
+ private _orders: Order[]
+ private _accountInfo: AccountInfo
+
+ // Spied methods
+ init = vi.fn<() => Promise>().mockResolvedValue(undefined)
+ close = vi.fn<() => Promise>().mockResolvedValue(undefined)
+
+ searchContracts = vi.fn<(pattern: string) => Promise>()
+ .mockResolvedValue([{ contract: makeContract() }])
+
+ getContractDetails = vi.fn<(query: Partial) => Promise>()
+ .mockResolvedValue({ contract: makeContract(), longName: 'Apple Inc.' })
+
+ placeOrder = vi.fn<(order: OrderRequest) => Promise>()
+ .mockResolvedValue(makeOrderResult())
+
+ modifyOrder = vi.fn<(orderId: string, changes: Partial) => Promise>()
+ .mockResolvedValue(makeOrderResult())
+
+ cancelOrder = vi.fn<(orderId: string) => Promise>()
+ .mockResolvedValue(true)
+
+ closePosition = vi.fn<(contract: Contract, qty?: number) => Promise>()
+ .mockResolvedValue(makeOrderResult())
+
+ getQuote = vi.fn<(contract: Contract) => Promise>()
+ .mockResolvedValue({
+ contract: makeContract(),
+ last: 160,
+ bid: 159.9,
+ ask: 160.1,
+ volume: 1_000_000,
+ timestamp: new Date(),
+ })
+
+ getMarketClock = vi.fn<() => Promise>()
+ .mockResolvedValue({
+ isOpen: true,
+ nextClose: new Date('2025-01-01T21:00:00Z'),
+ })
+
+ constructor(options: MockTradingAccountOptions = {}) {
+ this.id = options.id ?? 'mock-paper'
+ this.provider = options.provider ?? 'mock'
+ this.label = options.label ?? 'Mock Paper Account'
+ this._capabilities = { ...DEFAULT_CAPABILITIES, ...options.capabilities }
+ this._positions = options.positions ?? []
+ this._orders = options.orders ?? []
+ this._accountInfo = { ...DEFAULT_ACCOUNT_INFO, ...options.accountInfo }
+ }
+
+ getCapabilities(): AccountCapabilities {
+ return this._capabilities
+ }
+
+ getAccount = vi.fn<() => Promise>()
+ .mockImplementation(async () => this._accountInfo)
+
+ getPositions = vi.fn<() => Promise>()
+ .mockImplementation(async () => this._positions)
+
+ getOrders = vi.fn<() => Promise>()
+ .mockImplementation(async () => this._orders)
+
+ // ---- Test helpers ----
+
+ setPositions(positions: Position[]): void {
+ this._positions = positions
+ }
+
+ setOrders(orders: Order[]): void {
+ this._orders = orders
+ }
+
+ setAccountInfo(info: Partial): void {
+ this._accountInfo = { ...this._accountInfo, ...info }
+ }
+}
diff --git a/src/extension/trading/account-manager.spec.ts b/src/extension/trading/account-manager.spec.ts
new file mode 100644
index 00000000..cb075ad5
--- /dev/null
+++ b/src/extension/trading/account-manager.spec.ts
@@ -0,0 +1,184 @@
+import { describe, it, expect, beforeEach } from 'vitest'
+import { AccountManager } from './account-manager.js'
+import {
+ MockTradingAccount,
+ makeContract,
+} from './__test__/mock-account.js'
+
+describe('AccountManager', () => {
+ let manager: AccountManager
+
+ beforeEach(() => {
+ manager = new AccountManager()
+ })
+
+ // ==================== Registration ====================
+
+ describe('addAccount / removeAccount', () => {
+ it('adds and retrieves an account', () => {
+ const acct = new MockTradingAccount({ id: 'a1' })
+ manager.addAccount(acct)
+
+ expect(manager.getAccount('a1')).toBe(acct)
+ expect(manager.has('a1')).toBe(true)
+ expect(manager.size).toBe(1)
+ })
+
+ it('throws on duplicate id', () => {
+ manager.addAccount(new MockTradingAccount({ id: 'a1' }))
+ expect(() =>
+ manager.addAccount(new MockTradingAccount({ id: 'a1' })),
+ ).toThrow('already registered')
+ })
+
+ it('removes an account', () => {
+ manager.addAccount(new MockTradingAccount({ id: 'a1' }))
+ manager.removeAccount('a1')
+ expect(manager.has('a1')).toBe(false)
+ expect(manager.size).toBe(0)
+ })
+
+ it('returns undefined for unknown id', () => {
+ expect(manager.getAccount('nope')).toBeUndefined()
+ })
+ })
+
+ // ==================== listAccounts ====================
+
+ describe('listAccounts', () => {
+ it('returns summaries of all accounts', () => {
+ manager.addAccount(new MockTradingAccount({ id: 'a1', provider: 'alpaca', label: 'Paper' }))
+ manager.addAccount(new MockTradingAccount({ id: 'a2', provider: 'ccxt', label: 'Bybit' }))
+
+ const list = manager.listAccounts()
+ expect(list).toHaveLength(2)
+ expect(list[0].id).toBe('a1')
+ expect(list[0].provider).toBe('alpaca')
+ expect(list[1].id).toBe('a2')
+ })
+
+ it('includes platformId when provided', () => {
+ manager.addAccount(new MockTradingAccount({ id: 'a1', provider: 'alpaca' }), 'alpaca-paper')
+ manager.addAccount(new MockTradingAccount({ id: 'a2', provider: 'ccxt' }))
+
+ const list = manager.listAccounts()
+ expect(list[0].platformId).toBe('alpaca-paper')
+ expect(list[1].platformId).toBeUndefined()
+ })
+ })
+
+ // ==================== getAggregatedEquity ====================
+
+ describe('getAggregatedEquity', () => {
+ it('aggregates equity across accounts', async () => {
+ const a1 = new MockTradingAccount({ id: 'a1', label: 'A', accountInfo: { equity: 50_000, cash: 30_000, unrealizedPnL: 2_000, realizedPnL: 500 } })
+ const a2 = new MockTradingAccount({ id: 'a2', label: 'B', accountInfo: { equity: 75_000, cash: 60_000, unrealizedPnL: 3_000, realizedPnL: 1_000 } })
+ manager.addAccount(a1)
+ manager.addAccount(a2)
+
+ const result = await manager.getAggregatedEquity()
+ expect(result.totalEquity).toBe(125_000)
+ expect(result.totalCash).toBe(90_000)
+ expect(result.totalUnrealizedPnL).toBe(5_000)
+ expect(result.totalRealizedPnL).toBe(1_500)
+ expect(result.accounts).toHaveLength(2)
+ })
+
+ it('returns zeros when no accounts', async () => {
+ const result = await manager.getAggregatedEquity()
+ expect(result.totalEquity).toBe(0)
+ expect(result.accounts).toHaveLength(0)
+ })
+ })
+
+ // ==================== searchContracts ====================
+
+ describe('searchContracts', () => {
+ it('searches all accounts by default', async () => {
+ const a1 = new MockTradingAccount({ id: 'a1' })
+ a1.searchContracts.mockResolvedValue([{ contract: makeContract({ aliceId: 'a1-AAPL' }) }])
+ const a2 = new MockTradingAccount({ id: 'a2' })
+ a2.searchContracts.mockResolvedValue([{ contract: makeContract({ aliceId: 'a2-AAPL' }) }])
+
+ manager.addAccount(a1)
+ manager.addAccount(a2)
+
+ const results = await manager.searchContracts('AAPL')
+ expect(results).toHaveLength(2)
+ })
+
+ it('scopes search to specific accountId', async () => {
+ const a1 = new MockTradingAccount({ id: 'a1' })
+ a1.searchContracts.mockResolvedValue([{ contract: makeContract({ aliceId: 'a1-AAPL' }) }])
+ const a2 = new MockTradingAccount({ id: 'a2' })
+ a2.searchContracts.mockResolvedValue([{ contract: makeContract({ aliceId: 'a2-AAPL' }) }])
+
+ manager.addAccount(a1)
+ manager.addAccount(a2)
+
+ const results = await manager.searchContracts('AAPL', 'a1')
+ expect(results).toHaveLength(1)
+ expect(results[0].accountId).toBe('a1')
+ })
+
+ it('excludes accounts with no matches', async () => {
+ const a1 = new MockTradingAccount({ id: 'a1' })
+ a1.searchContracts.mockResolvedValue([])
+ const a2 = new MockTradingAccount({ id: 'a2' })
+ a2.searchContracts.mockResolvedValue([{ contract: makeContract() }])
+
+ manager.addAccount(a1)
+ manager.addAccount(a2)
+
+ const results = await manager.searchContracts('AAPL')
+ expect(results).toHaveLength(1)
+ expect(results[0].accountId).toBe('a2')
+ })
+ })
+
+ // ==================== getContractDetails ====================
+
+ describe('getContractDetails', () => {
+ it('returns details from specified account', async () => {
+ const a1 = new MockTradingAccount({ id: 'a1' })
+ manager.addAccount(a1)
+
+ const details = await manager.getContractDetails({ symbol: 'AAPL' }, 'a1')
+ expect(details).not.toBeNull()
+ expect(details!.contract.symbol).toBe('AAPL')
+ expect(details!.longName).toBe('Apple Inc.')
+ })
+
+ it('returns null for unknown account', async () => {
+ const details = await manager.getContractDetails({ symbol: 'AAPL' }, 'nope')
+ expect(details).toBeNull()
+ })
+ })
+
+ // ==================== closeAll ====================
+
+ describe('closeAll', () => {
+ it('calls close on all accounts and clears entries', async () => {
+ const a1 = new MockTradingAccount({ id: 'a1' })
+ const a2 = new MockTradingAccount({ id: 'a2' })
+ manager.addAccount(a1)
+ manager.addAccount(a2)
+
+ await manager.closeAll()
+
+ expect(a1.close).toHaveBeenCalled()
+ expect(a2.close).toHaveBeenCalled()
+ expect(manager.size).toBe(0)
+ })
+
+ it('does not throw if one account fails to close', async () => {
+ const a1 = new MockTradingAccount({ id: 'a1' })
+ a1.close.mockRejectedValue(new Error('close failed'))
+ manager.addAccount(a1)
+
+ // Should not throw
+ await manager.closeAll()
+ expect(manager.size).toBe(0)
+ })
+ })
+})
diff --git a/src/extension/trading/account-manager.ts b/src/extension/trading/account-manager.ts
new file mode 100644
index 00000000..dd406147
--- /dev/null
+++ b/src/extension/trading/account-manager.ts
@@ -0,0 +1,183 @@
+/**
+ * AccountManager — multi-account registry and aggregation
+ *
+ * Holds all ITradingAccount instances, provides cross-account operations
+ * like aggregated equity and global contract search.
+ */
+
+import type { Contract, ContractDescription, ContractDetails } from './contract.js'
+import type { ITradingAccount, AccountCapabilities } from './interfaces.js'
+
+// ==================== Account entry ====================
+
+export interface AccountEntry {
+ account: ITradingAccount
+ platformId?: string
+}
+
+export interface AccountSummary {
+ id: string
+ provider: string
+ label: string
+ platformId?: string
+ capabilities: AccountCapabilities
+}
+
+// ==================== Aggregated equity ====================
+
+export interface AggregatedEquity {
+ totalEquity: number
+ totalCash: number
+ totalUnrealizedPnL: number
+ totalRealizedPnL: number
+ accounts: Array<{
+ id: string
+ label: string
+ equity: number
+ cash: number
+ unrealizedPnL: number
+ }>
+}
+
+// ==================== Contract search result ====================
+
+export interface ContractSearchResult {
+ accountId: string
+ results: ContractDescription[]
+}
+
+// ==================== AccountManager ====================
+
+export class AccountManager {
+ private entries = new Map()
+
+ // ---- Registration ----
+
+ addAccount(account: ITradingAccount, platformId?: string): void {
+ if (this.entries.has(account.id)) {
+ throw new Error(`Account "${account.id}" already registered`)
+ }
+ this.entries.set(account.id, { account, platformId })
+ }
+
+ removeAccount(id: string): void {
+ this.entries.delete(id)
+ }
+
+ // ---- Lookups ----
+
+ getAccount(id: string): ITradingAccount | undefined {
+ return this.entries.get(id)?.account
+ }
+
+ listAccounts(): AccountSummary[] {
+ return Array.from(this.entries.values()).map(({ account, platformId }) => ({
+ id: account.id,
+ provider: account.provider,
+ label: account.label,
+ platformId,
+ capabilities: account.getCapabilities(),
+ }))
+ }
+
+ has(id: string): boolean {
+ return this.entries.has(id)
+ }
+
+ get size(): number {
+ return this.entries.size
+ }
+
+ // ---- Cross-account aggregation ----
+
+ /** Throttle: only warn once per account per 5 minutes */
+ private equityWarnedAt = new Map()
+ private static readonly EQUITY_WARN_INTERVAL_MS = 5 * 60_000
+
+ async getAggregatedEquity(): Promise {
+ const results = await Promise.all(
+ Array.from(this.entries.values()).map(async ({ account }) => {
+ try {
+ const info = await account.getAccount()
+ return { id: account.id, label: account.label, info }
+ } catch (err) {
+ const now = Date.now()
+ const lastWarned = this.equityWarnedAt.get(account.id) ?? 0
+ if (now - lastWarned > AccountManager.EQUITY_WARN_INTERVAL_MS) {
+ console.warn(`getAggregatedEquity: ${account.id} failed, skipping:`, err)
+ this.equityWarnedAt.set(account.id, now)
+ }
+ return { id: account.id, label: account.label, info: null }
+ }
+ }),
+ )
+
+ let totalEquity = 0
+ let totalCash = 0
+ let totalUnrealizedPnL = 0
+ let totalRealizedPnL = 0
+ const accounts: AggregatedEquity['accounts'] = []
+
+ for (const { id, label, info } of results) {
+ if (!info) continue
+ totalEquity += info.equity
+ totalCash += info.cash
+ totalUnrealizedPnL += info.unrealizedPnL
+ totalRealizedPnL += info.realizedPnL
+ accounts.push({
+ id,
+ label,
+ equity: info.equity,
+ cash: info.cash,
+ unrealizedPnL: info.unrealizedPnL,
+ })
+ }
+
+ return { totalEquity, totalCash, totalUnrealizedPnL, totalRealizedPnL, accounts }
+ }
+
+ // ---- Cross-account contract search ----
+
+ /**
+ * Fuzzy search all accounts for matching contracts (IBKR: reqMatchingSymbols).
+ * If accountId is specified, only searches that account.
+ */
+ async searchContracts(
+ pattern: string,
+ accountId?: string,
+ ): Promise {
+ const targets = accountId
+ ? [this.entries.get(accountId)].filter(Boolean) as AccountEntry[]
+ : Array.from(this.entries.values())
+
+ const results = await Promise.all(
+ targets.map(async ({ account }) => {
+ const descriptions = await account.searchContracts(pattern)
+ return { accountId: account.id, results: descriptions }
+ }),
+ )
+
+ return results.filter((r) => r.results.length > 0)
+ }
+
+ /**
+ * Get full contract details from a specific account (IBKR: reqContractDetails).
+ */
+ async getContractDetails(
+ query: Partial,
+ accountId: string,
+ ): Promise {
+ const entry = this.entries.get(accountId)
+ if (!entry) return null
+ return entry.account.getContractDetails(query)
+ }
+
+ // ---- Lifecycle ----
+
+ async closeAll(): Promise {
+ await Promise.allSettled(
+ Array.from(this.entries.values()).map(({ account }) => account.close()),
+ )
+ this.entries.clear()
+ }
+}
diff --git a/src/extension/trading/adapter.ts b/src/extension/trading/adapter.ts
new file mode 100644
index 00000000..4cf467a8
--- /dev/null
+++ b/src/extension/trading/adapter.ts
@@ -0,0 +1,867 @@
+/**
+ * Unified Trading Tool Factory — multi-account source routing
+ *
+ * Creates ONE set of AI tools that route to accounts via `source` parameter.
+ * Query tools default to all accounts (aggregated with source tags).
+ * Staging mutations (placeOrder, closePosition, cancelOrder) require explicit `source`.
+ * Git-flow mutations (tradingCommit, tradingPush, tradingSync) default to all accounts.
+ *
+ * Replaces the old per-account `createTradingTools(account, git)` pattern
+ * and the separate `git/adapter.ts`.
+ */
+
+import { tool } from 'ai'
+import { z } from 'zod'
+import type { AccountManager } from './account-manager.js'
+import type { ITradingAccount } from './interfaces.js'
+import type { ITradingGit } from './git/interfaces.js'
+import type { GitState, OrderStatusUpdate } from './git/types.js'
+
+// ==================== Resolver interface ====================
+
+export interface AccountResolver {
+ accountManager: AccountManager
+ getGit: (accountId: string) => ITradingGit | undefined
+ getGitState: (accountId: string) => Promise | undefined
+}
+
+// ==================== Exported helpers (used by provider tools) ====================
+
+export interface ResolvedAccount {
+ account: ITradingAccount
+ id: string
+}
+
+export function resolveAccounts(
+ mgr: AccountManager,
+ source?: string,
+): ResolvedAccount[] {
+ const summaries = mgr.listAccounts()
+ if (!source) {
+ return summaries
+ .map((s) => ({ account: mgr.getAccount(s.id)!, id: s.id }))
+ .filter((r) => r.account)
+ }
+ // Try id match first, then provider match
+ const byId = mgr.getAccount(source)
+ if (byId) return [{ account: byId, id: source }]
+
+ const byProvider = summaries
+ .filter((s) => s.provider === source)
+ .map((s) => ({ account: mgr.getAccount(s.id)!, id: s.id }))
+ .filter((r) => r.account)
+ return byProvider
+}
+
+export function resolveOne(
+ mgr: AccountManager,
+ source: string,
+): ResolvedAccount {
+ const results = resolveAccounts(mgr, source)
+ if (results.length === 0) {
+ throw new Error(`No account found matching source "${source}". Use listAccounts to see available accounts.`)
+ }
+ if (results.length > 1) {
+ throw new Error(
+ `Multiple accounts match source "${source}": ${results.map((r) => r.id).join(', ')}. Use account id for exact match.`,
+ )
+ }
+ return results[0]
+}
+
+function requireGit(resolver: AccountResolver, accountId: string): ITradingGit {
+ const git = resolver.getGit(accountId)
+ if (!git) throw new Error(`No git instance for account "${accountId}"`)
+ return git
+}
+
+const sourceDesc = (required: boolean, extra?: string) => {
+ const base = `Account source — matches account id (e.g. "alpaca-paper") or provider (e.g. "alpaca", "ccxt").`
+ const req = required
+ ? ' Required for this operation.'
+ : ' Optional — omit to query all accounts.'
+ return base + req + (extra ? ` ${extra}` : '')
+}
+
+// ==================== Tool factory ====================
+
+export function createTradingTools(resolver: AccountResolver) {
+ const { accountManager } = resolver
+
+ return {
+ // ==================== Discovery ====================
+
+ listAccounts: tool({
+ description:
+ 'List all registered trading accounts with their id, provider, label, and capabilities. ' +
+ 'Use this to discover available `source` values for other tools.',
+ inputSchema: z.object({}),
+ execute: () => {
+ return accountManager.listAccounts()
+ },
+ }),
+
+ // ==================== Contract Search (IBKR: reqMatchingSymbols) ====================
+
+ searchContracts: tool({
+ description: `Search broker accounts for tradeable contracts matching a pattern.
+
+This is a BROKER-LEVEL search — it queries your connected trading accounts to find
+what contracts are available to trade and on which account. Returns contract details
+with source attribution.
+
+When to use:
+- You need to discover which account can trade a symbol (e.g. "Is BTC on ccxt or alpaca?")
+- You want to find the exact broker contract format (e.g. "BTC" → "BTC/USDT:USDT" on ccxt)
+- You're unsure if a symbol is tradeable on a specific account
+
+When NOT to use:
+- You already have the symbol and just need a price → use getQuote directly
+- You want to research companies or market data → use marketSearchForResearch instead
+- You're about to place an order with a known symbol → placeOrder handles routing itself`,
+ inputSchema: z.object({
+ pattern: z.string().describe('Symbol or keyword to search (e.g. "AAPL", "BTC", "TSLA")'),
+ source: z.string().optional().describe(sourceDesc(false)),
+ }),
+ execute: async ({ pattern, source }) => {
+ const targets = resolveAccounts(accountManager, source)
+ if (targets.length === 0) return { error: 'No accounts available.' }
+
+ const allResults: Array> = []
+
+ for (const { account, id } of targets) {
+ try {
+ const descriptions = await account.searchContracts(pattern)
+ for (const desc of descriptions) {
+ allResults.push({
+ source: id,
+ ...desc,
+ })
+ }
+ } catch {
+ // Skip accounts that fail to search
+ }
+ }
+
+ if (allResults.length === 0) return { results: [], message: `No contracts found matching "${pattern}".` }
+ return allResults
+ },
+ }),
+
+ // ==================== Contract Details (IBKR: reqContractDetails) ====================
+
+ getContractDetails: tool({
+ description: `Get full contract specification from a specific broker account.
+
+Returns detailed broker-level information: supported order types, valid exchanges,
+price increments (minTick), trading hours, and contract classification.
+
+Use this when you need broker-specific contract specs (e.g. what order types are
+supported, minimum tick size). NOT needed for general company info — use
+equityGetProfile for that.`,
+ inputSchema: z.object({
+ source: z.string().describe(sourceDesc(true)),
+ symbol: z.string().optional().describe('Symbol to look up (e.g. "AAPL", "BTC")'),
+ aliceId: z.string().optional().describe('Alice contract ID for exact match'),
+ secType: z.string().optional().describe('Security type filter (e.g. "STK", "CRYPTO")'),
+ currency: z.string().optional().describe('Currency filter (e.g. "USD", "USDT")'),
+ }),
+ execute: async ({ source, symbol, aliceId, secType, currency }) => {
+ const { account, id } = resolveOne(accountManager, source)
+
+ const query: Record = {}
+ if (symbol) query.symbol = symbol
+ if (aliceId) query.aliceId = aliceId
+ if (secType) query.secType = secType
+ if (currency) query.currency = currency
+
+ const details = await account.getContractDetails(query)
+ if (!details) return { error: `No contract details found.` }
+ return { source: id, ...details }
+ },
+ }),
+
+ // ==================== Account info (query, aggregatable) ====================
+
+ getAccount: tool({
+ description:
+ 'Query trading account info (cash, portfolioValue, equity, buyingPower, unrealizedPnL, realizedPnL, dayTradeCount).',
+ inputSchema: z.object({
+ source: z.string().optional().describe(sourceDesc(false)),
+ }),
+ execute: async ({ source }) => {
+ const targets = resolveAccounts(accountManager, source)
+ if (targets.length === 0) return { error: 'No accounts available.' }
+
+ const results = await Promise.all(
+ targets.map(async ({ account, id }) => {
+ const info = await account.getAccount()
+ return { source: id, ...info }
+ }),
+ )
+ return results.length === 1 ? results[0] : results
+ },
+ }),
+
+ // ==================== Portfolio (query, aggregatable) ====================
+
+ getPortfolio: tool({
+ description: `Query current portfolio holdings.
+
+Each holding includes:
+- symbol, side, qty, avgEntryPrice, currentPrice
+- marketValue: Current market value
+- unrealizedPnL / unrealizedPnLPercent: Unrealized profit/loss
+- costBasis: Total cost basis
+- percentageOfEquity: This holding's value as percentage of total equity
+- percentageOfPortfolio: This holding's value as percentage of total portfolio
+
+IMPORTANT: If result is an empty array [], you have no holdings.`,
+ inputSchema: z.object({
+ source: z.string().optional().describe(sourceDesc(false)),
+ symbol: z
+ .string()
+ .optional()
+ .describe('Filter by ticker (e.g. "AAPL"), or omit for all holdings'),
+ }),
+ execute: async ({ source, symbol }) => {
+ const targets = resolveAccounts(accountManager, source)
+ if (targets.length === 0) return { positions: [], message: 'No accounts available.' }
+
+ const allPositions: Array> = []
+
+ for (const { account, id } of targets) {
+ const positions = await account.getPositions()
+ const accountInfo = await account.getAccount()
+
+ const totalMarketValue = positions.reduce((sum, p) => sum + p.marketValue, 0)
+
+ for (const pos of positions) {
+ if (symbol && symbol !== 'all' && pos.contract.symbol !== symbol) continue
+
+ const percentOfEquity =
+ accountInfo.equity > 0 ? (pos.marketValue / accountInfo.equity) * 100 : 0
+ const percentOfPortfolio =
+ totalMarketValue > 0 ? (pos.marketValue / totalMarketValue) * 100 : 0
+
+ allPositions.push({
+ source: id,
+ symbol: pos.contract.symbol,
+ side: pos.side,
+ qty: pos.qty,
+ avgEntryPrice: pos.avgEntryPrice,
+ currentPrice: pos.currentPrice,
+ marketValue: pos.marketValue,
+ unrealizedPnL: pos.unrealizedPnL,
+ unrealizedPnLPercent: pos.unrealizedPnLPercent,
+ costBasis: pos.costBasis,
+ leverage: pos.leverage,
+ margin: pos.margin,
+ liquidationPrice: pos.liquidationPrice,
+ percentageOfEquity: `${percentOfEquity.toFixed(1)}%`,
+ percentageOfPortfolio: `${percentOfPortfolio.toFixed(1)}%`,
+ })
+ }
+ }
+
+ if (allPositions.length === 0) {
+ return { positions: [], message: 'No open positions.' }
+ }
+ return allPositions
+ },
+ }),
+
+ // ==================== Orders (query, aggregatable) ====================
+
+ getOrders: tool({
+ description: 'Query order history (filled, pending, cancelled)',
+ inputSchema: z.object({
+ source: z.string().optional().describe(sourceDesc(false)),
+ }),
+ execute: async ({ source }) => {
+ const targets = resolveAccounts(accountManager, source)
+ if (targets.length === 0) return []
+
+ const results = await Promise.all(
+ targets.map(async ({ account, id }) => {
+ const orders = await account.getOrders()
+ return orders.map((o) => ({ source: id, ...o }))
+ }),
+ )
+ return results.flat()
+ },
+ }),
+
+ // ==================== Quote (query, optional source) ====================
+
+ getQuote: tool({
+ description: `Query the latest quote/price for a contract.
+
+Returns real-time market data from the broker:
+- last: last traded price
+- bid/ask: current best bid and ask
+- volume: today's trading volume
+
+Use searchContracts first to get the aliceId, then pass it here.`,
+ inputSchema: z.object({
+ aliceId: z.string().describe('Contract identifier from searchContracts (e.g. "alpaca-AAPL", "bybit-BTCUSDT")'),
+ source: z.string().optional().describe(sourceDesc(false)),
+ }),
+ execute: async ({ aliceId, source }) => {
+ const targets = resolveAccounts(accountManager, source)
+ if (targets.length === 0) return { error: 'No accounts available.' }
+
+ const results: Array> = []
+ for (const { account, id } of targets) {
+ try {
+ const quote = await account.getQuote({ aliceId })
+ results.push({ source: id, ...quote })
+ } catch {
+ // Skip accounts that don't support this contract
+ }
+ }
+
+ if (results.length === 0) return { error: `No account could quote aliceId "${aliceId}".` }
+ return results.length === 1 ? results[0] : results
+ },
+ }),
+
+ // ==================== Market Clock (query, optional source) ====================
+
+ getMarketClock: tool({
+ description:
+ 'Get current market clock status (isOpen, nextOpen, nextClose). Use this to check if the market is currently open for trading.',
+ inputSchema: z.object({
+ source: z.string().optional().describe(sourceDesc(false)),
+ }),
+ execute: async ({ source }) => {
+ const targets = resolveAccounts(accountManager, source)
+ if (targets.length === 0) return { error: 'No accounts available.' }
+
+ const results = await Promise.all(
+ targets.map(async ({ account, id }) => {
+ const clock = await account.getMarketClock()
+ return { source: id, ...clock }
+ }),
+ )
+ return results.length === 1 ? results[0] : results
+ },
+ }),
+
+ // ==================== Trading Log (query, aggregatable) ====================
+
+ tradingLog: tool({
+ description: `View your trading decision history (like "git log --stat").
+
+IMPORTANT: Check this BEFORE making new trading decisions to:
+- Review what you planned in recent commits
+- Avoid contradicting your own strategy
+- Maintain consistency across rounds
+
+Returns recent trading commits in reverse chronological order (newest first).
+Each commit includes:
+- hash: Unique commit identifier
+- message: Your explanation for the trades
+- operations: Summary of each operation (symbol, action, change, status)
+- timestamp: When the commit was made
+
+Use symbol parameter to filter commits for a specific ticker.
+Use tradingShow(hash) for full details of a specific commit.`,
+ inputSchema: z.object({
+ source: z.string().optional().describe(sourceDesc(false)),
+ limit: z
+ .number()
+ .int()
+ .positive()
+ .optional()
+ .describe('Number of recent commits to return (default: 10)'),
+ symbol: z
+ .string()
+ .optional()
+ .describe(
+ 'Filter commits by symbol (e.g., "AAPL"). Only shows commits that affected this symbol.',
+ ),
+ }),
+ execute: ({ source, limit, symbol }) => {
+ const targets = resolveAccounts(accountManager, source)
+ if (targets.length === 0) return []
+
+ const allEntries: Array> = []
+ for (const { id } of targets) {
+ const git = resolver.getGit(id)
+ if (!git) continue
+ const entries = git.log({ limit, symbol })
+ for (const entry of entries) {
+ allEntries.push({ source: id, ...entry })
+ }
+ }
+
+ // Sort by timestamp descending
+ allEntries.sort((a, b) => {
+ const ta = new Date(a.timestamp as string).getTime()
+ const tb = new Date(b.timestamp as string).getTime()
+ return tb - ta
+ })
+
+ return limit ? allEntries.slice(0, limit) : allEntries
+ },
+ }),
+
+ // ==================== Trading Show (query, auto-match by hash) ====================
+
+ tradingShow: tool({
+ description: `View details of a specific trading commit (like "git show ").
+
+Returns full commit information including:
+- All operations that were executed
+- Results of each operation (filled price, qty, errors)
+- Account state after the commit (holdings, cash)
+
+Use this to inspect what happened in a specific trading commit.`,
+ inputSchema: z.object({
+ hash: z.string().describe('Commit hash to inspect (8 characters)'),
+ }),
+ execute: ({ hash }) => {
+ // Search all gits for the hash
+ const summaries = accountManager.listAccounts()
+ for (const s of summaries) {
+ const git = resolver.getGit(s.id)
+ if (!git) continue
+ const commit = git.show(hash)
+ if (commit) return { source: s.id, ...commit }
+ }
+ return { error: `Commit ${hash} not found in any account` }
+ },
+ }),
+
+ // ==================== Trading Status (query, aggregatable) ====================
+
+ tradingStatus: tool({
+ description: `View current trading staging area status (like "git status").
+
+Returns:
+- staged: List of operations waiting to be committed/pushed
+- pendingMessage: Commit message if already committed but not pushed
+- head: Hash of the latest commit
+- commitCount: Total number of commits in history
+
+Use this to check if you have pending operations before making more trades.`,
+ inputSchema: z.object({
+ source: z.string().optional().describe(sourceDesc(false)),
+ }),
+ execute: ({ source }) => {
+ const targets = resolveAccounts(accountManager, source)
+ if (targets.length === 0) return []
+
+ const results: Array> = []
+ for (const { id } of targets) {
+ const git = resolver.getGit(id)
+ if (!git) continue
+ results.push({ source: id, ...git.status() })
+ }
+ return results.length === 1 ? results[0] : results
+ },
+ }),
+
+ // ==================== Simulate Price Change (query, aggregatable) ====================
+
+ simulatePriceChange: tool({
+ description: `Simulate price changes to see portfolio impact BEFORE making decisions (dry run).
+
+Use this tool to:
+- See how much you would lose if a stock drops
+- Understand the impact of market movements on your portfolio
+- Make informed decisions about position sizing
+
+Price change syntax:
+- Absolute: "@150" means price becomes $150
+- Relative: "+10%" means price increases by 10%, "-5%" means price decreases by 5%
+
+You can simulate changes for:
+- A specific symbol: { symbol: "AAPL", change: "@150" }
+- All holdings: { symbol: "all", change: "-10%" }
+
+IMPORTANT: This is READ-ONLY - it does NOT modify your actual holdings.`,
+ inputSchema: z.object({
+ source: z.string().optional().describe(sourceDesc(false)),
+ priceChanges: z
+ .array(
+ z.object({
+ symbol: z
+ .string()
+ .describe('Ticker (e.g., "AAPL") or "all" for all holdings'),
+ change: z
+ .string()
+ .describe(
+ 'Price change: "@150" for absolute, "+10%" or "-5%" for relative',
+ ),
+ }),
+ )
+ .describe('Array of price changes to simulate'),
+ }),
+ execute: async ({ source, priceChanges }) => {
+ const targets = resolveAccounts(accountManager, source)
+ if (targets.length === 0) return { error: 'No accounts available.' }
+
+ const results: Array> = []
+ for (const { id } of targets) {
+ const git = resolver.getGit(id)
+ if (!git) continue
+ const result = await git.simulatePriceChange(priceChanges)
+ results.push({ source: id, ...result })
+ }
+ return results.length === 1 ? results[0] : results
+ },
+ }),
+
+ // ==================== Place Order (mutation, source required) ====================
+
+ placeOrder: tool({
+ description: `Stage an order (will execute on tradingPush).
+
+BEFORE placing orders, you SHOULD:
+1. Check tradingLog({ source }) to review your history for THIS source
+2. Check getPortfolio to see current holdings
+3. Verify this trade aligns with your stated strategy
+
+Supports two modes:
+- qty-based: Specify number of shares (supports fractional, e.g. 0.5)
+- notional-based: Specify USD amount (e.g. $1000 of AAPL)
+
+For SELLING holdings, use closePosition tool instead.
+
+NOTE: This stages the operation. Call tradingCommit + tradingPush to execute.`,
+ inputSchema: z.object({
+ source: z.string().describe(sourceDesc(true)),
+ aliceId: z.string().describe('Contract identifier from searchContracts (e.g. "alpaca-AAPL", "bybit-BTCUSDT")'),
+ symbol: z.string().optional().describe('Human-readable symbol for logging (e.g. "AAPL", "BTC"). Optional — extracted from aliceId if omitted.'),
+ side: z.enum(['buy', 'sell']).describe('Buy or sell'),
+ type: z
+ .enum(['market', 'limit', 'stop', 'stop_limit', 'trailing_stop', 'trailing_stop_limit', 'moc'])
+ .describe('Order type'),
+ qty: z
+ .number()
+ .positive()
+ .optional()
+ .describe(
+ 'Number of shares (supports fractional). Mutually exclusive with notional.',
+ ),
+ notional: z
+ .number()
+ .positive()
+ .optional()
+ .describe(
+ 'Dollar amount to invest (e.g. 1000 = $1000 of the stock). Mutually exclusive with qty.',
+ ),
+ price: z
+ .number()
+ .positive()
+ .optional()
+ .describe('Limit price (required for limit and stop_limit orders)'),
+ stopPrice: z
+ .number()
+ .positive()
+ .optional()
+ .describe(
+ 'Stop trigger price (required for stop and stop_limit orders)',
+ ),
+ trailingAmount: z
+ .number()
+ .positive()
+ .optional()
+ .describe('Trailing stop absolute offset in dollars (for trailing_stop/trailing_stop_limit)'),
+ trailingPercent: z
+ .number()
+ .positive()
+ .optional()
+ .describe('Trailing stop percentage (for trailing_stop/trailing_stop_limit)'),
+ reduceOnly: z
+ .boolean()
+ .optional()
+ .describe('Only reduce position (close only)'),
+ timeInForce: z
+ .enum(['day', 'gtc', 'ioc', 'fok', 'opg', 'gtd'])
+ .default('day')
+ .describe('Time in force (default: day)'),
+ goodTillDate: z
+ .string()
+ .optional()
+ .describe('Expiration date for GTD orders (ISO date string)'),
+ extendedHours: z
+ .boolean()
+ .optional()
+ .describe('Allow pre-market and after-hours trading'),
+ parentId: z
+ .string()
+ .optional()
+ .describe('Parent order ID for bracket orders (child references parent)'),
+ ocaGroup: z
+ .string()
+ .optional()
+ .describe('One-Cancels-All group name'),
+ }),
+ execute: ({
+ source,
+ aliceId,
+ symbol,
+ side,
+ type,
+ qty,
+ notional,
+ price,
+ stopPrice,
+ trailingAmount,
+ trailingPercent,
+ reduceOnly,
+ timeInForce,
+ goodTillDate,
+ extendedHours,
+ parentId,
+ ocaGroup,
+ }) => {
+ const { id } = resolveOne(accountManager, source)
+ const git = requireGit(resolver, id)
+ return git.add({
+ action: 'placeOrder',
+ params: {
+ aliceId,
+ symbol,
+ side,
+ type,
+ qty,
+ notional,
+ price,
+ stopPrice,
+ trailingAmount,
+ trailingPercent,
+ reduceOnly,
+ timeInForce,
+ goodTillDate,
+ extendedHours,
+ parentId,
+ ocaGroup,
+ },
+ })
+ },
+ }),
+
+ // ==================== Modify Order (mutation, source required) ====================
+
+ modifyOrder: tool({
+ description: `Stage an order modification (will execute on tradingPush).
+
+Modifies an existing pending order's price, quantity, or other parameters without cancelling and re-placing.
+IBKR-style replace semantics: the order keeps its ID but parameters change.
+
+NOTE: This stages the operation. Call tradingCommit + tradingPush to execute.`,
+ inputSchema: z.object({
+ source: z.string().describe(sourceDesc(true)),
+ orderId: z.string().describe('Order ID to modify'),
+ qty: z.number().positive().optional().describe('New quantity'),
+ price: z.number().positive().optional().describe('New limit price'),
+ stopPrice: z.number().positive().optional().describe('New stop trigger price'),
+ trailingAmount: z.number().positive().optional().describe('New trailing stop offset'),
+ trailingPercent: z.number().positive().optional().describe('New trailing stop percentage'),
+ type: z
+ .enum(['market', 'limit', 'stop', 'stop_limit', 'trailing_stop', 'trailing_stop_limit', 'moc'])
+ .optional()
+ .describe('New order type'),
+ timeInForce: z
+ .enum(['day', 'gtc', 'ioc', 'fok', 'opg', 'gtd'])
+ .optional()
+ .describe('New time in force'),
+ goodTillDate: z.string().optional().describe('New expiration date for GTD orders'),
+ }),
+ execute: ({ source, orderId, ...changes }) => {
+ const { id } = resolveOne(accountManager, source)
+ const git = requireGit(resolver, id)
+ return git.add({
+ action: 'modifyOrder',
+ params: { orderId, ...changes },
+ })
+ },
+ }),
+
+ // ==================== Close Position (mutation, source required) ====================
+
+ closePosition: tool({
+ description: `Stage a position close (will execute on tradingPush).
+
+This is the preferred way to sell holdings instead of using placeOrder with side="sell".
+
+NOTE: This stages the operation. Call tradingCommit + tradingPush to execute.`,
+ inputSchema: z.object({
+ source: z.string().describe(sourceDesc(true)),
+ aliceId: z.string().describe('Contract identifier from searchContracts or getPortfolio (e.g. "alpaca-AAPL")'),
+ symbol: z.string().optional().describe('Human-readable symbol for logging. Optional.'),
+ qty: z
+ .number()
+ .positive()
+ .optional()
+ .describe('Number of shares to sell (default: sell all)'),
+ }),
+ execute: ({ source, aliceId, symbol, qty }) => {
+ const { id } = resolveOne(accountManager, source)
+ const git = requireGit(resolver, id)
+ return git.add({
+ action: 'closePosition',
+ params: { aliceId, symbol, qty },
+ })
+ },
+ }),
+
+ // ==================== Cancel Order (mutation, source required) ====================
+
+ cancelOrder: tool({
+ description: `Stage an order cancellation (will execute on tradingPush).
+
+NOTE: This stages the operation. Call tradingCommit + tradingPush to execute.`,
+ inputSchema: z.object({
+ source: z.string().describe(sourceDesc(true)),
+ orderId: z.string().describe('Order ID to cancel'),
+ }),
+ execute: ({ source, orderId }) => {
+ const { id } = resolveOne(accountManager, source)
+ const git = requireGit(resolver, id)
+ return git.add({
+ action: 'cancelOrder',
+ params: { orderId },
+ })
+ },
+ }),
+
+ // ==================== Trading Commit (source optional — commits all if omitted) ====================
+
+ tradingCommit: tool({
+ description: `Commit staged trading operations with a message (like "git commit -m").
+
+After staging operations with placeOrder/closePosition/etc., use this to:
+1. Add a commit message explaining WHY you're making these trades
+2. Prepare the operations for execution
+
+This does NOT execute the trades yet - call tradingPush after this.
+
+If source is omitted, commits ALL accounts that have staged operations.
+
+Example workflow:
+1. placeOrder({ source: "alpaca", symbol: "AAPL", side: "buy", ... }) -> staged
+2. tradingCommit({ message: "Buying AAPL on strong earnings beat" })
+3. tradingPush() -> executes and records`,
+ inputSchema: z.object({
+ source: z.string().optional().describe(sourceDesc(false, 'If omitted, commits all accounts with staged operations.')),
+ message: z
+ .string()
+ .describe('Commit message explaining your trading decision'),
+ }),
+ execute: ({ source, message }) => {
+ const targets = resolveAccounts(accountManager, source)
+ const results: Array> = []
+
+ for (const { id } of targets) {
+ const git = resolver.getGit(id)
+ if (!git) continue
+ const status = git.status()
+ if (status.staged.length === 0) continue
+ results.push({ source: id, ...git.commit(message) })
+ }
+
+ if (results.length === 0) return { message: 'No staged operations to commit.' }
+ return results.length === 1 ? results[0] : results
+ },
+ }),
+
+ // ==================== Trading Push (source optional — pushes all if omitted) ====================
+
+ tradingPush: tool({
+ description: `Execute all committed trading operations (like "git push").
+
+After staging operations and committing them, use this to:
+1. Execute all staged operations against the broker
+2. Record the commit with results to trading history
+
+Returns execution results for each operation (filled/pending/rejected).
+
+If source is omitted, pushes ALL accounts that have committed operations.
+
+IMPORTANT: You must call tradingCommit first before pushing.`,
+ inputSchema: z.object({
+ source: z.string().optional().describe(sourceDesc(false, 'If omitted, pushes all accounts with committed operations.')),
+ }),
+ execute: async ({ source }) => {
+ const targets = resolveAccounts(accountManager, source)
+ const results: Array> = []
+
+ for (const { id } of targets) {
+ const git = resolver.getGit(id)
+ if (!git) continue
+ const status = git.status()
+ if (!status.pendingMessage) continue
+ const result = await git.push()
+ results.push({ source: id, ...result })
+ }
+
+ if (results.length === 0) return { message: 'No committed operations to push.' }
+ return results.length === 1 ? results[0] : results
+ },
+ }),
+
+ // ==================== Trading Sync (source optional — syncs all if omitted) ====================
+
+ tradingSync: tool({
+ description: `Sync pending order statuses from broker (like "git pull").
+
+Checks all pending orders from previous commits and fetches their latest
+status from the broker. Creates a sync commit recording any changes.
+
+If source is omitted, syncs ALL accounts that have pending orders.
+
+Use this after placing limit/stop orders to check if they've been filled.`,
+ inputSchema: z.object({
+ source: z.string().optional().describe(sourceDesc(false, 'If omitted, syncs all accounts with pending orders.')),
+ }),
+ execute: async ({ source }) => {
+ const targets = resolveAccounts(accountManager, source)
+ const results: Array> = []
+
+ for (const { id, account } of targets) {
+ const git = resolver.getGit(id)
+ if (!git) continue
+ const gitState = resolver.getGitState(id)
+ if (!gitState) continue
+
+ const pendingOrders = git.getPendingOrderIds()
+ if (pendingOrders.length === 0) continue
+
+ const brokerOrders = await account.getOrders()
+ const updates: OrderStatusUpdate[] = []
+
+ for (const { orderId, symbol } of pendingOrders) {
+ const brokerOrder = brokerOrders.find((o) => o.id === orderId)
+ if (!brokerOrder) continue
+
+ const newStatus = brokerOrder.status
+ if (newStatus !== 'pending') {
+ updates.push({
+ orderId,
+ symbol,
+ previousStatus: 'pending',
+ currentStatus: newStatus,
+ filledPrice: brokerOrder.filledPrice,
+ filledQty: brokerOrder.filledQty,
+ })
+ }
+ }
+
+ if (updates.length === 0) continue
+
+ const state = await gitState
+ const result = await git.sync(updates, state)
+ results.push({ source: id, ...result })
+ }
+
+ if (results.length === 0) return { message: 'No pending orders to sync.', updatedCount: 0 }
+ return results.length === 1 ? results[0] : results
+ },
+ }),
+ }
+}
diff --git a/src/extension/trading/contract.ts b/src/extension/trading/contract.ts
new file mode 100644
index 00000000..8e8fe2fe
--- /dev/null
+++ b/src/extension/trading/contract.ts
@@ -0,0 +1,147 @@
+/**
+ * Contract — IBKR-style instrument definition (1:1 replica)
+ *
+ * All fields are optional. A Contract serves both as a complete instrument
+ * definition and as a search query (like IBKR's reqContractDetails).
+ *
+ * The only deviation from IBKR: `conId` is replaced by `aliceId` — a global
+ * unique identifier with format "{provider}-{nativeId}".
+ * Examples: "alpaca-AAPL", "binance-BTCUSDT", "ibkr-265598"
+ */
+
+// ==================== Security type ====================
+
+export type SecType =
+ | 'STK' // Stock (or ETF)
+ | 'OPT' // Option
+ | 'FUT' // Future
+ | 'FOP' // Future Option
+ | 'CASH' // Forex pair
+ | 'BOND' // Bond
+ | 'WAR' // Warrant
+ | 'CMDTY' // Commodity
+ | 'CRYPTO' // Cryptocurrency
+ | 'FUND' // Mutual Fund
+ | 'IND' // Index
+ | 'BAG' // Combo (multi-leg)
+
+// ==================== Option type ====================
+
+export type OptionType = 'P' | 'PUT' | 'C' | 'CALL'
+
+// ==================== Combo leg ====================
+
+export interface ComboLeg {
+ conId: number
+ ratio: number
+ action: 'BUY' | 'SELL'
+ exchange: string
+}
+
+// ==================== Delta neutral ====================
+
+export interface DeltaNeutralContract {
+ conId: number
+ delta: number
+ price: number
+}
+
+// ==================== Contract ====================
+
+export interface Contract {
+ /** Global unique ID: "{provider}-{nativeId}". */
+ aliceId?: string
+
+ /** The underlying's asset symbol. */
+ symbol?: string
+
+ /** Security type. */
+ secType?: SecType
+
+ /**
+ * Last trading day or contract month.
+ * YYYYMM = contract month, YYYYMMDD = last trading day.
+ * For Options and Futures only.
+ */
+ lastTradeDateOrContractMonth?: string
+
+ /** Option strike price. */
+ strike?: number
+
+ /** Option right: Put or Call. */
+ right?: OptionType
+
+ /** Instrument multiplier (options, futures). */
+ multiplier?: number
+
+ /** Destination exchange. */
+ exchange?: string
+
+ /** Trading currency. */
+ currency?: string
+
+ /** Contract symbol within primary exchange (OCC symbol for US options). */
+ localSymbol?: string
+
+ /** Native exchange of the contract (for smart-routing disambiguation). */
+ primaryExch?: string
+
+ /** Trading class name (e.g. "FGBL" for Euro-Bund futures). */
+ tradingClass?: string
+
+ /** If true, include expired contracts in queries. */
+ includeExpired?: boolean
+
+ /** Security identifier type: "ISIN", "CUSIP", "SEDOL", "RIC". */
+ secIdType?: string
+
+ /** Security identifier value. */
+ secId?: string
+
+ /** Human-readable description. */
+ description?: string
+
+ /** Issuer ID. */
+ issuerId?: string
+
+ /** Textual description of combo legs. */
+ comboLegsDescription?: string
+
+ /** Legs of a combined contract definition. */
+ comboLegs?: ComboLeg[]
+
+ /** Delta-neutral combo order parameters. */
+ deltaNeutralContract?: DeltaNeutralContract
+}
+
+// ==================== Contract Description (IBKR: reqMatchingSymbols result) ====================
+
+/** Lightweight search result from searchContracts — matches IBKR ContractDescription. */
+export interface ContractDescription {
+ contract: Contract
+ /** Derivative security types available for this underlying (e.g. OPT, FUT). */
+ derivativeSecTypes?: SecType[]
+}
+
+// ==================== Contract Details (IBKR: reqContractDetails result) ====================
+
+/** Full contract specification from getContractDetails — matches IBKR ContractDetails. */
+export interface ContractDetails {
+ contract: Contract
+ longName?: string // "Apple Inc.", "Bitcoin Perpetual"
+ industry?: string // "Technology"
+ category?: string // "Computers"
+ subcategory?: string // "Consumer Electronics"
+ marketName?: string // "NMS", "ISLAND"
+ minTick?: number // minimum price increment
+ priceMagnifier?: number // price display factor
+ orderTypes?: string[] // supported order types for this contract
+ validExchanges?: string[] // exchanges where this can be traded
+ tradingHours?: string // trading hours description
+ liquidHours?: string // liquid trading hours
+ timeZone?: string // timezone ID
+ stockType?: string // "COMMON", "ETF", "ADR"
+ contractMonth?: string // for futures/options: "202506"
+ underlyingSymbol?: string // for derivatives: underlying symbol
+ underlyingSecType?: SecType // for derivatives: underlying type
+}
diff --git a/src/extension/trading/factory.spec.ts b/src/extension/trading/factory.spec.ts
new file mode 100644
index 00000000..ab815896
--- /dev/null
+++ b/src/extension/trading/factory.spec.ts
@@ -0,0 +1,100 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { wireAccountTrading } from './factory.js'
+import { MockTradingAccount, makeOrderResult } from './__test__/mock-account.js'
+
+describe('wireAccountTrading', () => {
+ let account: MockTradingAccount
+
+ beforeEach(() => {
+ account = new MockTradingAccount()
+ })
+
+ it('returns AccountSetup with account, git, and getGitState', () => {
+ const setup = wireAccountTrading(account, {})
+
+ expect(setup.account).toBe(account)
+ expect(setup.git).toBeDefined()
+ expect(typeof setup.getGitState).toBe('function')
+ })
+
+ it('creates a functional git that can add/commit/push', async () => {
+ account.placeOrder.mockResolvedValue(makeOrderResult())
+
+ const { git } = wireAccountTrading(account, {})
+
+ git.add({
+ action: 'placeOrder',
+ params: { symbol: 'AAPL', side: 'buy', type: 'market', qty: 10 },
+ })
+ git.commit('Buy AAPL')
+ const result = await git.push()
+
+ expect(result.operationCount).toBe(1)
+ expect(account.placeOrder).toHaveBeenCalled()
+ })
+
+ it('wires guards that can reject operations', async () => {
+ const { git } = wireAccountTrading(account, {
+ guards: [{ type: 'symbol-whitelist', options: { symbols: ['GOOG'] } }],
+ })
+
+ git.add({
+ action: 'placeOrder',
+ params: { symbol: 'AAPL', side: 'buy', type: 'market', qty: 10 },
+ })
+ git.commit('Should be blocked')
+ const result = await git.push()
+
+ // Guard should reject AAPL (not in whitelist)
+ expect(result.rejected).toHaveLength(1)
+ expect(account.placeOrder).not.toHaveBeenCalled()
+ })
+
+ it('calls onCommit callback after push', async () => {
+ const onCommit = vi.fn()
+ account.placeOrder.mockResolvedValue(makeOrderResult())
+
+ const { git } = wireAccountTrading(account, { onCommit })
+
+ git.add({
+ action: 'placeOrder',
+ params: { symbol: 'AAPL', side: 'buy', type: 'market', qty: 10 },
+ })
+ git.commit('Buy')
+ await git.push()
+
+ expect(onCommit).toHaveBeenCalledTimes(1)
+ const state = onCommit.mock.calls[0][0]
+ expect(state.commits).toHaveLength(1)
+ })
+
+ it('restores from saved state', async () => {
+ account.placeOrder.mockResolvedValue(makeOrderResult())
+
+ // Create initial state
+ const { git: git1 } = wireAccountTrading(account, {})
+ git1.add({
+ action: 'placeOrder',
+ params: { symbol: 'AAPL', side: 'buy', type: 'market', qty: 10 },
+ })
+ git1.commit('First trade')
+ await git1.push()
+ const savedState = git1.exportState()
+
+ // Restore
+ const { git: git2 } = wireAccountTrading(account, { savedState })
+ expect(git2.status().commitCount).toBe(1)
+ expect(git2.log()[0].message).toBe('First trade')
+ })
+
+ it('getGitState returns state from account', async () => {
+ const { getGitState } = wireAccountTrading(account, {})
+ const state = await getGitState()
+
+ expect(state.cash).toBe(100_000)
+ expect(state.equity).toBe(105_000)
+ expect(account.getAccount).toHaveBeenCalled()
+ expect(account.getPositions).toHaveBeenCalled()
+ expect(account.getOrders).toHaveBeenCalled()
+ })
+})
diff --git a/src/extension/trading/factory.ts b/src/extension/trading/factory.ts
new file mode 100644
index 00000000..b424731a
--- /dev/null
+++ b/src/extension/trading/factory.ts
@@ -0,0 +1,98 @@
+/**
+ * Trading Account Factory
+ *
+ * Wires an ITradingAccount with TradingGit, guards, and operation dispatcher.
+ * Also provides config-to-account creation helpers.
+ */
+
+import type { ITradingAccount } from './interfaces.js'
+import type { ITradingGit } from './git/interfaces.js'
+import type { GitExportState, GitState } from './git/types.js'
+import { TradingGit } from './git/TradingGit.js'
+import { createOperationDispatcher } from './operation-dispatcher.js'
+import { createWalletStateBridge } from './wallet-state-bridge.js'
+import { createGuardPipeline, resolveGuards } from './guards/index.js'
+import { AlpacaAccount } from './providers/alpaca/index.js'
+import { CcxtAccount } from './providers/ccxt/index.js'
+import type { Config } from '../../core/config.js'
+
+// ==================== AccountSetup ====================
+
+export interface AccountSetup {
+ account: ITradingAccount
+ git: ITradingGit
+ getGitState: () => Promise
+}
+
+// ==================== Wiring ====================
+
+/**
+ * Wire an ITradingAccount with TradingGit + guards + dispatcher.
+ * Does NOT call account.init() — caller is responsible for lifecycle.
+ */
+export function wireAccountTrading(
+ account: ITradingAccount,
+ options: {
+ guards?: Array<{ type: string; options?: Record }>
+ savedState?: GitExportState
+ onCommit?: (state: GitExportState) => void | Promise
+ },
+): AccountSetup {
+ const getGitState = createWalletStateBridge(account)
+ const dispatcher = createOperationDispatcher(account)
+ const guards = resolveGuards(options.guards ?? [])
+ const guardedDispatcher = createGuardPipeline(dispatcher, account, guards)
+
+ const git = options.savedState
+ ? TradingGit.restore(options.savedState, {
+ executeOperation: guardedDispatcher,
+ getGitState,
+ onCommit: options.onCommit,
+ })
+ : new TradingGit({
+ executeOperation: guardedDispatcher,
+ getGitState,
+ onCommit: options.onCommit,
+ })
+
+ return { account, git, getGitState }
+}
+
+// ==================== Config → Account helpers ====================
+
+/**
+ * Create an AlpacaAccount from securities config section.
+ * Returns null if provider type is 'none'.
+ */
+export function createAlpacaFromConfig(
+ config: Config['securities'],
+): AlpacaAccount | null {
+ if (config.provider.type === 'none') return null
+ const { apiKey, secretKey, paper } = config.provider
+ return new AlpacaAccount({
+ apiKey: apiKey ?? '',
+ secretKey: secretKey ?? '',
+ paper,
+ })
+}
+
+/**
+ * Create a CcxtAccount from crypto config section.
+ * Returns null if provider type is 'none'.
+ */
+export function createCcxtFromConfig(
+ config: Config['crypto'],
+): CcxtAccount | null {
+ if (config.provider.type === 'none') return null
+ const p = config.provider
+ return new CcxtAccount({
+ exchange: p.exchange,
+ apiKey: p.apiKey ?? '',
+ apiSecret: p.apiSecret ?? '',
+ password: p.password,
+ sandbox: p.sandbox,
+ demoTrading: p.demoTrading,
+ defaultMarketType: p.defaultMarketType,
+ options: p.options,
+ })
+}
diff --git a/src/extension/trading/git/TradingGit.spec.ts b/src/extension/trading/git/TradingGit.spec.ts
new file mode 100644
index 00000000..0add9d11
--- /dev/null
+++ b/src/extension/trading/git/TradingGit.spec.ts
@@ -0,0 +1,551 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { TradingGit } from './TradingGit.js'
+import type { TradingGitConfig } from './interfaces.js'
+import type { Operation, GitState } from './types.js'
+
+// ==================== Helpers ====================
+
+function makeGitState(overrides: Partial = {}): GitState {
+ return {
+ cash: 100_000,
+ equity: 105_000,
+ unrealizedPnL: 5_000,
+ realizedPnL: 1_000,
+ positions: [],
+ pendingOrders: [],
+ ...overrides,
+ }
+}
+
+function makeConfig(overrides: Partial = {}): TradingGitConfig {
+ return {
+ executeOperation: overrides.executeOperation ?? vi.fn().mockResolvedValue({
+ success: true,
+ order: { id: 'order-1', status: 'filled', filledPrice: 150, filledQty: 10 },
+ }),
+ getGitState: overrides.getGitState ?? vi.fn().mockResolvedValue(makeGitState()),
+ onCommit: overrides.onCommit,
+ }
+}
+
+function buyOp(symbol = 'AAPL'): Operation {
+ return {
+ action: 'placeOrder',
+ params: { symbol, side: 'buy', type: 'market', qty: 10 },
+ }
+}
+
+function sellOp(symbol = 'AAPL'): Operation {
+ return {
+ action: 'closePosition',
+ params: { symbol },
+ }
+}
+
+// ==================== Tests ====================
+
+describe('TradingGit', () => {
+ let config: TradingGitConfig
+ let git: TradingGit
+
+ beforeEach(() => {
+ config = makeConfig()
+ git = new TradingGit(config)
+ })
+
+ // ==================== add ====================
+
+ describe('add', () => {
+ it('stages an operation and returns AddResult', () => {
+ const result = git.add(buyOp())
+ expect(result.staged).toBe(true)
+ expect(result.index).toBe(0)
+ expect(result.operation.action).toBe('placeOrder')
+ })
+
+ it('increments index for multiple adds', () => {
+ git.add(buyOp('AAPL'))
+ const r2 = git.add(buyOp('GOOG'))
+ expect(r2.index).toBe(1)
+ })
+
+ it('shows staged operations in status', () => {
+ git.add(buyOp())
+ const status = git.status()
+ expect(status.staged).toHaveLength(1)
+ expect(status.pendingMessage).toBeNull()
+ })
+ })
+
+ // ==================== commit ====================
+
+ describe('commit', () => {
+ it('prepares a commit with hash and message', () => {
+ git.add(buyOp())
+ const result = git.commit('Buy AAPL')
+ expect(result.prepared).toBe(true)
+ expect(result.hash).toHaveLength(8)
+ expect(result.message).toBe('Buy AAPL')
+ expect(result.operationCount).toBe(1)
+ })
+
+ it('throws when staging area is empty', () => {
+ expect(() => git.commit('empty commit')).toThrow('Nothing to commit')
+ })
+
+ it('updates status with pending message', () => {
+ git.add(buyOp())
+ git.commit('msg')
+ const status = git.status()
+ expect(status.pendingMessage).toBe('msg')
+ })
+ })
+
+ // ==================== push ====================
+
+ describe('push', () => {
+ it('executes operations and returns PushResult', async () => {
+ git.add(buyOp())
+ git.commit('Buy AAPL')
+ const result = await git.push()
+
+ expect(result.hash).toHaveLength(8)
+ expect(result.message).toBe('Buy AAPL')
+ expect(result.operationCount).toBe(1)
+ expect(result.filled).toHaveLength(1)
+ expect(result.pending).toHaveLength(0)
+ expect(result.rejected).toHaveLength(0)
+ })
+
+ it('calls executeOperation for each staged op', async () => {
+ git.add(buyOp('AAPL'))
+ git.add(buyOp('GOOG'))
+ git.commit('Two buys')
+ await git.push()
+
+ expect(config.executeOperation).toHaveBeenCalledTimes(2)
+ })
+
+ it('calls getGitState after execution', async () => {
+ git.add(buyOp())
+ git.commit('msg')
+ await git.push()
+
+ expect(config.getGitState).toHaveBeenCalled()
+ })
+
+ it('clears staging area after push', async () => {
+ git.add(buyOp())
+ git.commit('msg')
+ await git.push()
+
+ const status = git.status()
+ expect(status.staged).toHaveLength(0)
+ expect(status.pendingMessage).toBeNull()
+ })
+
+ it('throws when staging area is empty', async () => {
+ await expect(git.push()).rejects.toThrow('Nothing to push')
+ })
+
+ it('throws when not committed', async () => {
+ git.add(buyOp())
+ await expect(git.push()).rejects.toThrow('please commit first')
+ })
+
+ it('calls onCommit callback with exported state', async () => {
+ const onCommit = vi.fn()
+ const gitWithCb = new TradingGit({ ...config, onCommit })
+
+ gitWithCb.add(buyOp())
+ gitWithCb.commit('msg')
+ await gitWithCb.push()
+
+ expect(onCommit).toHaveBeenCalledTimes(1)
+ const exported = onCommit.mock.calls[0][0]
+ expect(exported.commits).toHaveLength(1)
+ expect(exported.head).toHaveLength(8)
+ })
+
+ it('handles rejected operations gracefully', async () => {
+ const failConfig = makeConfig({
+ executeOperation: vi.fn().mockResolvedValue({ success: false, error: 'Insufficient funds' }),
+ })
+ const gitFail = new TradingGit(failConfig)
+
+ gitFail.add(buyOp())
+ gitFail.commit('msg')
+ const result = await gitFail.push()
+
+ expect(result.rejected).toHaveLength(1)
+ expect(result.filled).toHaveLength(0)
+ })
+
+ it('handles operation exceptions', async () => {
+ const failConfig = makeConfig({
+ executeOperation: vi.fn().mockRejectedValue(new Error('Network error')),
+ })
+ const gitFail = new TradingGit(failConfig)
+
+ gitFail.add(buyOp())
+ gitFail.commit('msg')
+ const result = await gitFail.push()
+
+ expect(result.rejected).toHaveLength(1)
+ expect(result.rejected[0].error).toBe('Network error')
+ })
+
+ it('categorizes pending orders correctly', async () => {
+ const pendingConfig = makeConfig({
+ executeOperation: vi.fn().mockResolvedValue({
+ success: true,
+ order: { id: 'order-2', status: 'pending' },
+ }),
+ })
+ const gitPending = new TradingGit(pendingConfig)
+
+ gitPending.add(buyOp())
+ gitPending.commit('limit order')
+ const result = await gitPending.push()
+
+ expect(result.pending).toHaveLength(1)
+ expect(result.filled).toHaveLength(0)
+ })
+ })
+
+ // ==================== log ====================
+
+ describe('log', () => {
+ it('returns empty array when no commits', () => {
+ expect(git.log()).toEqual([])
+ })
+
+ it('returns commits in reverse chronological order', async () => {
+ git.add(buyOp('AAPL'))
+ git.commit('First')
+ await git.push()
+
+ git.add(buyOp('GOOG'))
+ git.commit('Second')
+ await git.push()
+
+ const entries = git.log()
+ expect(entries).toHaveLength(2)
+ expect(entries[0].message).toBe('Second')
+ expect(entries[1].message).toBe('First')
+ })
+
+ it('filters by symbol', async () => {
+ git.add(buyOp('AAPL'))
+ git.commit('Buy AAPL')
+ await git.push()
+
+ git.add(buyOp('GOOG'))
+ git.commit('Buy GOOG')
+ await git.push()
+
+ const entries = git.log({ symbol: 'AAPL' })
+ expect(entries).toHaveLength(1)
+ expect(entries[0].message).toBe('Buy AAPL')
+ })
+
+ it('respects limit', async () => {
+ for (let i = 0; i < 5; i++) {
+ git.add(buyOp('AAPL'))
+ git.commit(`Commit ${i}`)
+ await git.push()
+ }
+
+ const entries = git.log({ limit: 2 })
+ expect(entries).toHaveLength(2)
+ })
+
+ it('includes operation summaries', async () => {
+ git.add(buyOp('AAPL'))
+ git.commit('Buy')
+ await git.push()
+
+ const entries = git.log()
+ expect(entries[0].operations).toHaveLength(1)
+ expect(entries[0].operations[0].symbol).toBe('AAPL')
+ expect(entries[0].operations[0].action).toBe('placeOrder')
+ })
+ })
+
+ // ==================== show ====================
+
+ describe('show', () => {
+ it('returns null for unknown hash', () => {
+ expect(git.show('deadbeef')).toBeNull()
+ })
+
+ it('returns the full commit for a known hash', async () => {
+ git.add(buyOp())
+ const { hash } = git.commit('msg')
+ await git.push()
+
+ const commit = git.show(hash)
+ expect(commit).not.toBeNull()
+ expect(commit!.hash).toBe(hash)
+ expect(commit!.message).toBe('msg')
+ expect(commit!.operations).toHaveLength(1)
+ expect(commit!.results).toHaveLength(1)
+ })
+ })
+
+ // ==================== status ====================
+
+ describe('status', () => {
+ it('reports clean state initially', () => {
+ const s = git.status()
+ expect(s.staged).toHaveLength(0)
+ expect(s.pendingMessage).toBeNull()
+ expect(s.head).toBeNull()
+ expect(s.commitCount).toBe(0)
+ })
+
+ it('tracks head and commitCount after push', async () => {
+ git.add(buyOp())
+ git.commit('msg')
+ await git.push()
+
+ const s = git.status()
+ expect(s.head).toHaveLength(8)
+ expect(s.commitCount).toBe(1)
+ })
+ })
+
+ // ==================== exportState / restore ====================
+
+ describe('exportState / restore', () => {
+ it('round-trips state', async () => {
+ git.add(buyOp('AAPL'))
+ git.commit('Buy AAPL')
+ await git.push()
+
+ const exported = git.exportState()
+ expect(exported.commits).toHaveLength(1)
+ expect(exported.head).toHaveLength(8)
+
+ const restored = TradingGit.restore(exported, config)
+ expect(restored.status().commitCount).toBe(1)
+ expect(restored.status().head).toBe(exported.head)
+
+ const log = restored.log()
+ expect(log).toHaveLength(1)
+ expect(log[0].message).toBe('Buy AAPL')
+ })
+ })
+
+ // ==================== setCurrentRound ====================
+
+ describe('setCurrentRound', () => {
+ it('tags commits with the current round', async () => {
+ git.setCurrentRound(42)
+ git.add(buyOp())
+ git.commit('msg')
+ await git.push()
+
+ const commit = git.show(git.status().head!)
+ expect(commit!.round).toBe(42)
+ })
+ })
+
+ // ==================== sync ====================
+
+ describe('sync', () => {
+ it('creates a sync commit for order updates', async () => {
+ const state = makeGitState()
+ const result = await git.sync(
+ [
+ {
+ orderId: 'order-1',
+ symbol: 'AAPL',
+ previousStatus: 'pending',
+ currentStatus: 'filled',
+ filledPrice: 155,
+ filledQty: 10,
+ },
+ ],
+ state,
+ )
+
+ expect(result.updatedCount).toBe(1)
+ expect(result.hash).toHaveLength(8)
+ expect(git.status().commitCount).toBe(1)
+ })
+
+ it('returns empty result for no updates', async () => {
+ const result = await git.sync([], makeGitState())
+ expect(result.updatedCount).toBe(0)
+ })
+ })
+
+ // ==================== getPendingOrderIds ====================
+
+ describe('getPendingOrderIds', () => {
+ it('returns empty when no commits', () => {
+ expect(git.getPendingOrderIds()).toEqual([])
+ })
+
+ it('finds pending orders from commits', async () => {
+ const pendingConfig = makeConfig({
+ executeOperation: vi.fn().mockResolvedValue({
+ success: true,
+ order: { id: 'lmt-1', status: 'pending' },
+ }),
+ })
+ const gitP = new TradingGit(pendingConfig)
+
+ gitP.add(buyOp('AAPL'))
+ gitP.commit('limit buy')
+ await gitP.push()
+
+ const pending = gitP.getPendingOrderIds()
+ expect(pending).toHaveLength(1)
+ expect(pending[0]).toEqual({ orderId: 'lmt-1', symbol: 'AAPL' })
+ })
+
+ it('excludes orders that have been synced to filled', async () => {
+ const pendingConfig = makeConfig({
+ executeOperation: vi.fn().mockResolvedValue({
+ success: true,
+ order: { id: 'lmt-1', status: 'pending' },
+ }),
+ })
+ const gitP = new TradingGit(pendingConfig)
+
+ gitP.add(buyOp('AAPL'))
+ gitP.commit('limit buy')
+ await gitP.push()
+
+ // Sync to filled
+ await gitP.sync(
+ [{
+ orderId: 'lmt-1',
+ symbol: 'AAPL',
+ previousStatus: 'pending',
+ currentStatus: 'filled',
+ filledPrice: 155,
+ filledQty: 10,
+ }],
+ makeGitState(),
+ )
+
+ expect(gitP.getPendingOrderIds()).toHaveLength(0)
+ })
+ })
+
+ // ==================== simulatePriceChange ====================
+
+ describe('simulatePriceChange', () => {
+ it('returns empty state when no positions', async () => {
+ const result = await git.simulatePriceChange([{ symbol: 'AAPL', change: '-10%' }])
+ expect(result.success).toBe(true)
+ expect(result.summary.totalPnLChange).toBe(0)
+ })
+
+ it('simulates relative price change on long position', async () => {
+ const stateWithPositions = makeGitState({
+ positions: [
+ {
+ contract: { aliceId: 'mock-AAPL', symbol: 'AAPL' },
+ side: 'long',
+ qty: 10,
+ avgEntryPrice: 150,
+ currentPrice: 160,
+ marketValue: 1600,
+ unrealizedPnL: 100,
+ unrealizedPnLPercent: 6.67,
+ costBasis: 1500,
+ leverage: 1,
+ },
+ ],
+ })
+ const simConfig = makeConfig({
+ getGitState: vi.fn().mockResolvedValue(stateWithPositions),
+ })
+ const simGit = new TradingGit(simConfig)
+
+ const result = await simGit.simulatePriceChange([{ symbol: 'AAPL', change: '-10%' }])
+ expect(result.success).toBe(true)
+ // Price drops 10%: 160 → 144
+ const simPos = result.simulatedState.positions[0]
+ expect(simPos.simulatedPrice).toBe(144)
+ // PnL: (144 - 150) * 10 = -60
+ expect(simPos.unrealizedPnL).toBe(-60)
+ })
+
+ it('simulates absolute price change', async () => {
+ const stateWithPositions = makeGitState({
+ positions: [
+ {
+ contract: { aliceId: 'mock-AAPL', symbol: 'AAPL' },
+ side: 'long',
+ qty: 10,
+ avgEntryPrice: 150,
+ currentPrice: 160,
+ marketValue: 1600,
+ unrealizedPnL: 100,
+ unrealizedPnLPercent: 6.67,
+ costBasis: 1500,
+ leverage: 1,
+ },
+ ],
+ })
+ const simConfig = makeConfig({
+ getGitState: vi.fn().mockResolvedValue(stateWithPositions),
+ })
+ const simGit = new TradingGit(simConfig)
+
+ const result = await simGit.simulatePriceChange([{ symbol: 'AAPL', change: '@200' }])
+ expect(result.success).toBe(true)
+ expect(result.simulatedState.positions[0].simulatedPrice).toBe(200)
+ // PnL: (200 - 150) * 10 = 500
+ expect(result.simulatedState.positions[0].unrealizedPnL).toBe(500)
+ })
+
+ it('simulates "all" positions', async () => {
+ const stateWithPositions = makeGitState({
+ positions: [
+ {
+ contract: { symbol: 'AAPL' },
+ side: 'long', qty: 10, avgEntryPrice: 100, currentPrice: 100,
+ marketValue: 1000, unrealizedPnL: 0, unrealizedPnLPercent: 0, costBasis: 1000, leverage: 1,
+ },
+ {
+ contract: { symbol: 'GOOG' },
+ side: 'long', qty: 5, avgEntryPrice: 200, currentPrice: 200,
+ marketValue: 1000, unrealizedPnL: 0, unrealizedPnLPercent: 0, costBasis: 1000, leverage: 1,
+ },
+ ],
+ })
+ const simConfig = makeConfig({ getGitState: vi.fn().mockResolvedValue(stateWithPositions) })
+ const simGit = new TradingGit(simConfig)
+
+ const result = await simGit.simulatePriceChange([{ symbol: 'all', change: '+10%' }])
+ expect(result.success).toBe(true)
+ expect(result.simulatedState.positions).toHaveLength(2)
+ expect(result.simulatedState.positions[0].simulatedPrice).toBeCloseTo(110)
+ expect(result.simulatedState.positions[1].simulatedPrice).toBeCloseTo(220)
+ })
+
+ it('returns error for invalid price change format', async () => {
+ const stateWithPositions = makeGitState({
+ positions: [
+ {
+ contract: { symbol: 'AAPL' },
+ side: 'long', qty: 10, avgEntryPrice: 100, currentPrice: 100,
+ marketValue: 1000, unrealizedPnL: 0, unrealizedPnLPercent: 0, costBasis: 1000, leverage: 1,
+ },
+ ],
+ })
+ const simConfig = makeConfig({ getGitState: vi.fn().mockResolvedValue(stateWithPositions) })
+ const simGit = new TradingGit(simConfig)
+
+ const result = await simGit.simulatePriceChange([{ symbol: 'AAPL', change: 'bad' }])
+ expect(result.success).toBe(false)
+ expect(result.error).toContain('Invalid change format')
+ })
+ })
+})
diff --git a/src/extension/trading/git/TradingGit.ts b/src/extension/trading/git/TradingGit.ts
new file mode 100644
index 00000000..59573bd2
--- /dev/null
+++ b/src/extension/trading/git/TradingGit.ts
@@ -0,0 +1,544 @@
+/**
+ * TradingGit — Trading-as-Git implementation
+ *
+ * Unified git-like operation tracking for all trading accounts.
+ * Merges crypto-trading/wallet/Wallet.ts and securities-trading/wallet/SecWallet.ts.
+ */
+
+import { createHash } from 'crypto'
+import type { ITradingGit, TradingGitConfig } from './interfaces.js'
+import type {
+ CommitHash,
+ Operation,
+ OperationResult,
+ AddResult,
+ CommitPrepareResult,
+ PushResult,
+ GitStatus,
+ GitCommit,
+ GitState,
+ CommitLogEntry,
+ GitExportState,
+ OperationSummary,
+ PriceChangeInput,
+ SimulatePriceChangeResult,
+ OrderStatusUpdate,
+ SyncResult,
+} from './types.js'
+
+function generateCommitHash(content: object): CommitHash {
+ const hash = createHash('sha256')
+ .update(JSON.stringify(content))
+ .digest('hex')
+ return hash.slice(0, 8)
+}
+
+export class TradingGit implements ITradingGit {
+ private stagingArea: Operation[] = []
+ private pendingMessage: string | null = null
+ private pendingHash: CommitHash | null = null
+ private commits: GitCommit[] = []
+ private head: CommitHash | null = null
+ private currentRound: number | undefined = undefined
+ private readonly config: TradingGitConfig
+
+ constructor(config: TradingGitConfig) {
+ this.config = config
+ }
+
+ // ==================== git add / commit / push ====================
+
+ add(operation: Operation): AddResult {
+ this.stagingArea.push(operation)
+ return {
+ staged: true,
+ index: this.stagingArea.length - 1,
+ operation,
+ }
+ }
+
+ commit(message: string): CommitPrepareResult {
+ if (this.stagingArea.length === 0) {
+ throw new Error('Nothing to commit: staging area is empty')
+ }
+
+ const timestamp = new Date().toISOString()
+ this.pendingHash = generateCommitHash({
+ message,
+ operations: this.stagingArea,
+ timestamp,
+ parentHash: this.head,
+ })
+ this.pendingMessage = message
+
+ return {
+ prepared: true,
+ hash: this.pendingHash,
+ message,
+ operationCount: this.stagingArea.length,
+ }
+ }
+
+ async push(): Promise {
+ if (this.stagingArea.length === 0) {
+ throw new Error('Nothing to push: staging area is empty')
+ }
+ if (this.pendingMessage === null || this.pendingHash === null) {
+ throw new Error('Nothing to push: please commit first')
+ }
+
+ const operations = [...this.stagingArea]
+ const message = this.pendingMessage
+ const hash = this.pendingHash
+
+ // Execute all operations
+ const results: OperationResult[] = []
+ for (const op of operations) {
+ try {
+ const raw = await this.config.executeOperation(op)
+ results.push(this.parseOperationResult(op, raw))
+ } catch (error) {
+ results.push({
+ action: op.action,
+ success: false,
+ status: 'rejected',
+ error: error instanceof Error ? error.message : String(error),
+ })
+ }
+ }
+
+ // Snapshot state after execution
+ const stateAfter = await this.config.getGitState()
+
+ const commit: GitCommit = {
+ hash,
+ parentHash: this.head,
+ message,
+ operations,
+ results,
+ stateAfter,
+ timestamp: new Date().toISOString(),
+ round: this.currentRound,
+ }
+
+ this.commits.push(commit)
+ this.head = hash
+
+ await this.config.onCommit?.(this.exportState())
+
+ // Clear staging
+ this.stagingArea = []
+ this.pendingMessage = null
+ this.pendingHash = null
+
+ const filled = results.filter((r) => r.status === 'filled')
+ const pending = results.filter((r) => r.status === 'pending')
+ const rejected = results.filter((r) => r.status === 'rejected' || !r.success)
+
+ return { hash, message, operationCount: operations.length, filled, pending, rejected }
+ }
+
+ // ==================== git log / show / status ====================
+
+ log(options: { limit?: number; symbol?: string } = {}): CommitLogEntry[] {
+ const { limit = 10, symbol } = options
+
+ let commits = this.commits.slice().reverse()
+
+ if (symbol) {
+ commits = commits.filter((c) =>
+ c.operations.some((op) => op.params.symbol === symbol),
+ )
+ }
+
+ commits = commits.slice(0, limit)
+
+ return commits.map((c) => ({
+ hash: c.hash,
+ parentHash: c.parentHash,
+ message: c.message,
+ timestamp: c.timestamp,
+ round: c.round,
+ operations: this.buildOperationSummaries(c, symbol),
+ }))
+ }
+
+ private buildOperationSummaries(
+ commit: GitCommit,
+ filterSymbol?: string,
+ ): OperationSummary[] {
+ const summaries: OperationSummary[] = []
+
+ for (let i = 0; i < commit.operations.length; i++) {
+ const op = commit.operations[i]
+ const result = commit.results[i]
+ const symbol = (op.params.symbol as string) || 'unknown'
+
+ if (filterSymbol && symbol !== filterSymbol) continue
+
+ summaries.push({
+ symbol,
+ action: op.action,
+ change: this.formatOperationChange(op, result),
+ status: result?.status || 'rejected',
+ })
+ }
+
+ return summaries
+ }
+
+ private formatOperationChange(op: Operation, result?: OperationResult): string {
+ const { action, params } = op
+
+ switch (action) {
+ case 'placeOrder': {
+ const side = params.side as string
+ const notional = params.notional as number | undefined
+ const qty = params.qty as number | undefined
+ const size = params.size as number | undefined
+ const usdSize = params.usd_size as number | undefined
+ // Unified: try notional/usd_size for dollar amount, fall back to qty/size for quantity
+ const sizeStr = notional ? `$${notional}` : usdSize ? `$${usdSize}` : qty ? `${qty}` : size ? `${size}` : '?'
+
+ if (result?.status === 'filled') {
+ const price = result.filledPrice ? ` @${result.filledPrice}` : ''
+ return `${side} ${sizeStr}${price}`
+ }
+ return `${side} ${sizeStr} (${result?.status || 'unknown'})`
+ }
+
+ case 'closePosition': {
+ const qty = (params.qty ?? params.size) as number | undefined
+ if (result?.status === 'filled') {
+ const price = result.filledPrice ? ` @${result.filledPrice}` : ''
+ const qtyStr = qty ? ` (partial: ${qty})` : ''
+ return `closed${qtyStr}${price}`
+ }
+ return `close (${result?.status || 'unknown'})`
+ }
+
+ case 'modifyOrder': {
+ const orderId = params.orderId as string
+ const changes = Object.keys(params).filter(k => k !== 'orderId')
+ return `modified ${orderId} (${changes.join(', ')})`
+ }
+
+ case 'cancelOrder':
+ return `cancelled order ${params.orderId}`
+
+ case 'syncOrders': {
+ const status = result?.status || 'unknown'
+ const price = result?.filledPrice ? ` @${result.filledPrice}` : ''
+ return `synced → ${status}${price}`
+ }
+
+ default:
+ return `${action}`
+ }
+ }
+
+ show(hash: CommitHash): GitCommit | null {
+ return this.commits.find((c) => c.hash === hash) ?? null
+ }
+
+ status(): GitStatus {
+ return {
+ staged: [...this.stagingArea],
+ pendingMessage: this.pendingMessage,
+ head: this.head,
+ commitCount: this.commits.length,
+ }
+ }
+
+ // ==================== Serialization ====================
+
+ exportState(): GitExportState {
+ return { commits: [...this.commits], head: this.head }
+ }
+
+ static restore(state: GitExportState, config: TradingGitConfig): TradingGit {
+ const git = new TradingGit(config)
+ git.commits = [...state.commits]
+ git.head = state.head
+ return git
+ }
+
+ setCurrentRound(round: number): void {
+ this.currentRound = round
+ }
+
+ // ==================== Sync ====================
+
+ async sync(updates: OrderStatusUpdate[], currentState: GitState): Promise {
+ if (updates.length === 0) {
+ return { hash: this.head ?? '', updatedCount: 0, updates: [] }
+ }
+
+ const hash = generateCommitHash({
+ updates,
+ timestamp: new Date().toISOString(),
+ parentHash: this.head,
+ })
+
+ const commit: GitCommit = {
+ hash,
+ parentHash: this.head,
+ message: `[sync] ${updates.length} order(s) updated`,
+ operations: [{ action: 'syncOrders', params: { orderIds: updates.map((u) => u.orderId) } }],
+ results: updates.map((u) => ({
+ action: 'syncOrders' as const,
+ success: true,
+ orderId: u.orderId,
+ status: u.currentStatus,
+ filledPrice: u.filledPrice,
+ filledQty: u.filledQty,
+ })),
+ stateAfter: currentState,
+ timestamp: new Date().toISOString(),
+ round: this.currentRound,
+ }
+
+ this.commits.push(commit)
+ this.head = hash
+
+ await this.config.onCommit?.(this.exportState())
+
+ return { hash, updatedCount: updates.length, updates }
+ }
+
+ getPendingOrderIds(): Array<{ orderId: string; symbol: string }> {
+ // Scan newest→oldest to find latest known status per orderId
+ const orderStatus = new Map()
+
+ for (let i = this.commits.length - 1; i >= 0; i--) {
+ for (const result of this.commits[i].results) {
+ if (result.orderId && !orderStatus.has(result.orderId)) {
+ orderStatus.set(result.orderId, result.status)
+ }
+ }
+ }
+
+ // Collect orders still pending
+ const pending: Array<{ orderId: string; symbol: string }> = []
+ const seen = new Set()
+
+ for (const commit of this.commits) {
+ for (let j = 0; j < commit.results.length; j++) {
+ const result = commit.results[j]
+ if (
+ result.orderId &&
+ !seen.has(result.orderId) &&
+ orderStatus.get(result.orderId) === 'pending'
+ ) {
+ const symbol = (commit.operations[j]?.params?.symbol as string) ?? 'unknown'
+ pending.push({ orderId: result.orderId, symbol })
+ seen.add(result.orderId)
+ }
+ }
+ }
+
+ return pending
+ }
+
+ // ==================== Simulation ====================
+
+ async simulatePriceChange(
+ priceChanges: PriceChangeInput[],
+ ): Promise {
+ const state = await this.config.getGitState()
+ const { positions, equity, unrealizedPnL, cash } = state
+
+ const currentTotalPnL = cash > 0 ? ((equity - cash) / cash) * 100 : 0
+
+ if (positions.length === 0) {
+ return {
+ success: true,
+ currentState: { equity, unrealizedPnL, totalPnL: currentTotalPnL, positions: [] },
+ simulatedState: { equity, unrealizedPnL, totalPnL: currentTotalPnL, positions: [] },
+ summary: {
+ totalPnLChange: 0,
+ equityChange: 0,
+ equityChangePercent: '0.0%',
+ worstCase: 'No positions to simulate.',
+ },
+ }
+ }
+
+ // Parse price changes → target price map
+ const priceMap = new Map()
+
+ for (const { symbol, change } of priceChanges) {
+ const parsed = this.parsePriceChange(change)
+ if (!parsed.success) {
+ return {
+ success: false,
+ error: `Invalid change format for ${symbol}: "${change}". Use "@150" for absolute or "+10%" / "-5%" for relative.`,
+ currentState: { equity, unrealizedPnL, totalPnL: currentTotalPnL, positions: [] },
+ simulatedState: { equity, unrealizedPnL, totalPnL: currentTotalPnL, positions: [] },
+ summary: { totalPnLChange: 0, equityChange: 0, equityChangePercent: '0.0%', worstCase: '' },
+ }
+ }
+
+ if (symbol === 'all') {
+ for (const pos of positions) {
+ priceMap.set(pos.contract.symbol ?? 'unknown', this.applyPriceChange(pos.currentPrice, parsed.type, parsed.value))
+ }
+ } else {
+ const pos = positions.find((p) => (p.contract.symbol ?? p.contract.aliceId) === symbol)
+ if (pos) {
+ priceMap.set(symbol, this.applyPriceChange(pos.currentPrice, parsed.type, parsed.value))
+ }
+ }
+ }
+
+ // Current state
+ const currentPositions = positions.map((pos) => ({
+ symbol: pos.contract.symbol ?? pos.contract.aliceId ?? 'unknown',
+ side: pos.side,
+ qty: pos.qty,
+ avgEntryPrice: pos.avgEntryPrice,
+ currentPrice: pos.currentPrice,
+ unrealizedPnL: pos.unrealizedPnL,
+ marketValue: pos.marketValue,
+ }))
+
+ // Simulated state
+ let simulatedUnrealizedPnL = 0
+ const simulatedPositions = positions.map((pos) => {
+ const sym = pos.contract.symbol ?? pos.contract.aliceId ?? 'unknown'
+ const simulatedPrice = priceMap.get(sym) ?? pos.currentPrice
+ const priceChange = simulatedPrice - pos.currentPrice
+ const priceChangePct = pos.currentPrice > 0 ? (priceChange / pos.currentPrice) * 100 : 0
+
+ const newPnL =
+ pos.side === 'long'
+ ? (simulatedPrice - pos.avgEntryPrice) * pos.qty
+ : (pos.avgEntryPrice - simulatedPrice) * pos.qty
+
+ const pnlChange = newPnL - pos.unrealizedPnL
+ simulatedUnrealizedPnL += newPnL
+
+ return {
+ symbol: sym,
+ side: pos.side,
+ qty: pos.qty,
+ avgEntryPrice: pos.avgEntryPrice,
+ simulatedPrice,
+ unrealizedPnL: newPnL,
+ marketValue: simulatedPrice * pos.qty,
+ pnlChange,
+ priceChangePercent: `${priceChangePct >= 0 ? '+' : ''}${priceChangePct.toFixed(2)}%`,
+ }
+ })
+
+ const pnlDiff = simulatedUnrealizedPnL - unrealizedPnL
+ const simulatedEquity = equity + pnlDiff
+ const simulatedTotalPnL = cash > 0 ? ((simulatedEquity - cash) / cash) * 100 : 0
+ const equityChangePct = equity > 0 ? (pnlDiff / equity) * 100 : 0
+
+ const worst = simulatedPositions.reduce(
+ (w, p) => (p.pnlChange < w.pnlChange ? p : w),
+ simulatedPositions[0],
+ )
+
+ const worstCase =
+ worst.pnlChange < 0
+ ? `${worst.symbol} would lose $${Math.abs(worst.pnlChange).toFixed(2)} (${worst.priceChangePercent})`
+ : 'All positions would profit or break even.'
+
+ return {
+ success: true,
+ currentState: { equity, unrealizedPnL, totalPnL: currentTotalPnL, positions: currentPositions },
+ simulatedState: {
+ equity: simulatedEquity,
+ unrealizedPnL: simulatedUnrealizedPnL,
+ totalPnL: simulatedTotalPnL,
+ positions: simulatedPositions,
+ },
+ summary: {
+ totalPnLChange: pnlDiff,
+ equityChange: pnlDiff,
+ equityChangePercent: `${equityChangePct >= 0 ? '+' : ''}${equityChangePct.toFixed(2)}%`,
+ worstCase,
+ },
+ }
+ }
+
+ private parsePriceChange(
+ change: string,
+ ): { success: true; type: 'absolute' | 'relative'; value: number } | { success: false } {
+ const trimmed = change.trim()
+
+ if (trimmed.startsWith('@')) {
+ const value = parseFloat(trimmed.slice(1))
+ if (isNaN(value) || value <= 0) return { success: false }
+ return { success: true, type: 'absolute', value }
+ }
+
+ if (trimmed.endsWith('%')) {
+ const value = parseFloat(trimmed.slice(0, -1))
+ if (isNaN(value)) return { success: false }
+ return { success: true, type: 'relative', value }
+ }
+
+ return { success: false }
+ }
+
+ private applyPriceChange(
+ currentPrice: number,
+ type: 'absolute' | 'relative',
+ value: number,
+ ): number {
+ return type === 'absolute' ? value : currentPrice * (1 + value / 100)
+ }
+
+ // ==================== Internal ====================
+
+ private parseOperationResult(op: Operation, raw: unknown): OperationResult {
+ const rawObj = raw as Record
+
+ if (!rawObj || typeof rawObj !== 'object') {
+ return {
+ action: op.action,
+ success: false,
+ status: 'rejected',
+ error: 'Invalid response from trading engine',
+ raw,
+ }
+ }
+
+ const success = rawObj.success === true
+ const order = rawObj.order as Record | undefined
+
+ if (!success) {
+ return {
+ action: op.action,
+ success: false,
+ status: 'rejected',
+ error: (rawObj.error as string) ?? 'Unknown error',
+ raw,
+ }
+ }
+
+ if (!order) {
+ // Operations without an order result
+ return { action: op.action, success: true, status: 'filled', raw }
+ }
+
+ const status = order.status as string
+ const isFilled = status === 'filled'
+ const isPending = status === 'pending'
+
+ return {
+ action: op.action,
+ success: true,
+ orderId: order.id as string | undefined,
+ status: isFilled ? 'filled' : isPending ? 'pending' : 'rejected',
+ filledPrice: isFilled ? (order.filledPrice as number) : undefined,
+ filledQty: isFilled
+ ? ((order.filledQty ?? order.filledQuantity ?? order.qty ?? order.size) as number)
+ : undefined,
+ raw,
+ }
+ }
+}
diff --git a/src/extension/trading/git/index.ts b/src/extension/trading/git/index.ts
new file mode 100644
index 00000000..f10ca889
--- /dev/null
+++ b/src/extension/trading/git/index.ts
@@ -0,0 +1,24 @@
+export { TradingGit } from './TradingGit.js'
+export type { ITradingGit, TradingGitConfig } from './interfaces.js'
+export type {
+ CommitHash,
+ Operation,
+ OperationAction,
+ OperationResult,
+ OperationStatus,
+ AddResult,
+ CommitPrepareResult,
+ PushResult,
+ GitStatus,
+ GitCommit,
+ GitState,
+ CommitLogEntry,
+ GitExportState,
+ OperationSummary,
+ OrderStatusUpdate,
+ SyncResult,
+ PriceChangeInput,
+ SimulatePriceChangeResult,
+ SimulationPositionCurrent,
+ SimulationPositionAfter,
+} from './types.js'
diff --git a/src/extension/trading/git/interfaces.ts b/src/extension/trading/git/interfaces.ts
new file mode 100644
index 00000000..49615e30
--- /dev/null
+++ b/src/extension/trading/git/interfaces.ts
@@ -0,0 +1,57 @@
+/**
+ * ITradingGit — Trading-as-Git interface
+ *
+ * Git-style three-phase workflow for trading operations:
+ * add → commit → push → log / show / status
+ */
+
+import type {
+ CommitHash,
+ Operation,
+ AddResult,
+ CommitPrepareResult,
+ PushResult,
+ GitStatus,
+ GitCommit,
+ CommitLogEntry,
+ GitExportState,
+ GitState,
+ PriceChangeInput,
+ SimulatePriceChangeResult,
+ OrderStatusUpdate,
+ SyncResult,
+} from './types.js'
+
+export interface ITradingGit {
+ // ---- git add / commit / push ----
+
+ add(operation: Operation): AddResult
+ commit(message: string): CommitPrepareResult
+ push(): Promise
+
+ // ---- git log / show / status ----
+
+ log(options?: { limit?: number; symbol?: string }): CommitLogEntry[]
+ show(hash: CommitHash): GitCommit | null
+ status(): GitStatus
+
+ // ---- git pull (sync pending orders) ----
+
+ sync(updates: OrderStatusUpdate[], currentState: GitState): Promise
+ getPendingOrderIds(): Array<{ orderId: string; symbol: string }>
+
+ // ---- serialization ----
+
+ exportState(): GitExportState
+ setCurrentRound(round: number): void
+
+ // ---- simulation ----
+
+ simulatePriceChange(priceChanges: PriceChangeInput[]): Promise
+}
+
+export interface TradingGitConfig {
+ executeOperation: (operation: Operation) => Promise
+ getGitState: () => Promise
+ onCommit?: (state: GitExportState) => void | Promise
+}
diff --git a/src/extension/trading/git/types.ts b/src/extension/trading/git/types.ts
new file mode 100644
index 00000000..bdd54724
--- /dev/null
+++ b/src/extension/trading/git/types.ts
@@ -0,0 +1,192 @@
+/**
+ * Trading-as-Git type definitions
+ *
+ * Unified git-like state management for tracking trading operation history.
+ * Merges crypto-trading/wallet/types.ts and securities-trading/wallet/types.ts.
+ */
+
+import type { Position, Order } from '../interfaces.js'
+
+// ==================== Commit Hash ====================
+
+/** 8-character short SHA-256 hash. */
+export type CommitHash = string
+
+// ==================== Operation ====================
+
+export type OperationAction =
+ | 'placeOrder'
+ | 'modifyOrder'
+ | 'closePosition'
+ | 'cancelOrder'
+ | 'syncOrders'
+
+export interface Operation {
+ action: OperationAction
+ params: Record
+}
+
+// ==================== Operation Result ====================
+
+export type OperationStatus = 'filled' | 'pending' | 'rejected' | 'cancelled' | 'partially_filled'
+
+export interface OperationResult {
+ action: OperationAction
+ success: boolean
+ orderId?: string
+ status: OperationStatus
+ filledPrice?: number
+ filledQty?: number
+ error?: string
+ raw?: unknown
+}
+
+// ==================== Wallet State ====================
+
+/** State snapshot taken after each commit. Uses unified Position/Order types. */
+export interface GitState {
+ cash: number
+ equity: number
+ unrealizedPnL: number
+ realizedPnL: number
+ positions: Position[]
+ pendingOrders: Order[]
+}
+
+// ==================== Commit ====================
+
+export interface GitCommit {
+ hash: CommitHash
+ parentHash: CommitHash | null
+ message: string
+ operations: Operation[]
+ results: OperationResult[]
+ stateAfter: GitState
+ timestamp: string
+ round?: number
+}
+
+// ==================== API Results ====================
+
+export interface AddResult {
+ staged: true
+ index: number
+ operation: Operation
+}
+
+export interface CommitPrepareResult {
+ prepared: true
+ hash: CommitHash
+ message: string
+ operationCount: number
+}
+
+export interface PushResult {
+ hash: CommitHash
+ message: string
+ operationCount: number
+ filled: OperationResult[]
+ pending: OperationResult[]
+ rejected: OperationResult[]
+}
+
+export interface GitStatus {
+ staged: Operation[]
+ pendingMessage: string | null
+ head: CommitHash | null
+ commitCount: number
+}
+
+export interface OperationSummary {
+ symbol: string
+ action: OperationAction
+ change: string
+ status: OperationStatus
+}
+
+export interface CommitLogEntry {
+ hash: CommitHash
+ parentHash: CommitHash | null
+ message: string
+ timestamp: string
+ round?: number
+ operations: OperationSummary[]
+}
+
+// ==================== Export State ====================
+
+export interface GitExportState {
+ commits: GitCommit[]
+ head: CommitHash | null
+}
+
+// ==================== Sync ====================
+
+export interface OrderStatusUpdate {
+ orderId: string
+ symbol: string
+ previousStatus: OperationStatus
+ currentStatus: OperationStatus
+ filledPrice?: number
+ filledQty?: number
+}
+
+export interface SyncResult {
+ hash: CommitHash
+ updatedCount: number
+ updates: OrderStatusUpdate[]
+}
+
+// ==================== Simulate Price Change ====================
+
+export interface PriceChangeInput {
+ /** Contract aliceId or symbol, or "all". */
+ symbol: string
+ /** "@88000" (absolute) or "+10%" / "-5%" (relative). */
+ change: string
+}
+
+export interface SimulationPositionCurrent {
+ symbol: string
+ side: 'long' | 'short'
+ qty: number
+ avgEntryPrice: number
+ currentPrice: number
+ unrealizedPnL: number
+ marketValue: number
+}
+
+export interface SimulationPositionAfter {
+ symbol: string
+ side: 'long' | 'short'
+ qty: number
+ avgEntryPrice: number
+ simulatedPrice: number
+ unrealizedPnL: number
+ marketValue: number
+ pnlChange: number
+ priceChangePercent: string
+}
+
+export interface SimulatePriceChangeResult {
+ success: boolean
+ error?: string
+ currentState: {
+ equity: number
+ unrealizedPnL: number
+ totalPnL: number
+ positions: SimulationPositionCurrent[]
+ }
+ simulatedState: {
+ equity: number
+ unrealizedPnL: number
+ totalPnL: number
+ positions: SimulationPositionAfter[]
+ }
+ summary: {
+ totalPnLChange: number
+ equityChange: number
+ equityChangePercent: string
+ worstCase: string
+ }
+}
diff --git a/src/extension/trading/guards/cooldown.ts b/src/extension/trading/guards/cooldown.ts
new file mode 100644
index 00000000..273b7265
--- /dev/null
+++ b/src/extension/trading/guards/cooldown.ts
@@ -0,0 +1,32 @@
+import type { OperationGuard, GuardContext } from './types.js'
+
+const DEFAULT_MIN_INTERVAL_MS = 60_000
+
+export class CooldownGuard implements OperationGuard {
+ readonly name = 'cooldown'
+ private minIntervalMs: number
+ private lastTradeTime = new Map()
+
+ constructor(options: Record) {
+ this.minIntervalMs = Number(options.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS)
+ }
+
+ check(ctx: GuardContext): string | null {
+ if (ctx.operation.action !== 'placeOrder') return null
+
+ const symbol = ctx.operation.params.symbol as string
+ const now = Date.now()
+ const lastTime = this.lastTradeTime.get(symbol)
+
+ if (lastTime != null) {
+ const elapsed = now - lastTime
+ if (elapsed < this.minIntervalMs) {
+ const remaining = Math.ceil((this.minIntervalMs - elapsed) / 1000)
+ return `Cooldown active for ${symbol}: ${remaining}s remaining`
+ }
+ }
+
+ this.lastTradeTime.set(symbol, now)
+ return null
+ }
+}
diff --git a/src/extension/trading/guards/guard-pipeline.ts b/src/extension/trading/guards/guard-pipeline.ts
new file mode 100644
index 00000000..d769249f
--- /dev/null
+++ b/src/extension/trading/guards/guard-pipeline.ts
@@ -0,0 +1,37 @@
+/**
+ * Guard Pipeline
+ *
+ * The only place that touches the account: assembles a GuardContext,
+ * then passes it through the guard chain. Guards themselves never
+ * see the account.
+ */
+
+import type { Operation } from '../git/types.js'
+import type { ITradingAccount } from '../interfaces.js'
+import type { OperationGuard, GuardContext } from './types.js'
+
+export function createGuardPipeline(
+ dispatcher: (op: Operation) => Promise,
+ account: ITradingAccount,
+ guards: OperationGuard[],
+): (op: Operation) => Promise {
+ if (guards.length === 0) return dispatcher
+
+ return async (op: Operation): Promise => {
+ const [positions, accountInfo] = await Promise.all([
+ account.getPositions(),
+ account.getAccount(),
+ ])
+
+ const ctx: GuardContext = { operation: op, positions, account: accountInfo }
+
+ for (const guard of guards) {
+ const rejection = await guard.check(ctx)
+ if (rejection != null) {
+ return { success: false, error: `[guard:${guard.name}] ${rejection}` }
+ }
+ }
+
+ return dispatcher(op)
+ }
+}
diff --git a/src/extension/trading/guards/guards.spec.ts b/src/extension/trading/guards/guards.spec.ts
new file mode 100644
index 00000000..4b366965
--- /dev/null
+++ b/src/extension/trading/guards/guards.spec.ts
@@ -0,0 +1,300 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { MaxPositionSizeGuard } from './max-position-size.js'
+import { CooldownGuard } from './cooldown.js'
+import { SymbolWhitelistGuard } from './symbol-whitelist.js'
+import { createGuardPipeline } from './guard-pipeline.js'
+import { resolveGuards, registerGuard } from './registry.js'
+import type { GuardContext, OperationGuard } from './types.js'
+import type { Operation } from '../git/types.js'
+import type { AccountInfo, Position } from '../interfaces.js'
+import { MockTradingAccount, makePosition } from '../__test__/mock-account.js'
+
+// ==================== Helpers ====================
+
+function makeContext(overrides: {
+ operation?: Operation
+ positions?: Position[]
+ account?: Partial
+} = {}): GuardContext {
+ return {
+ operation: overrides.operation ?? {
+ action: 'placeOrder',
+ params: { symbol: 'AAPL', side: 'buy', type: 'market', qty: 10 },
+ },
+ positions: overrides.positions ?? [],
+ account: {
+ cash: 100_000,
+ equity: 100_000,
+ unrealizedPnL: 0,
+ realizedPnL: 0,
+ ...overrides.account,
+ },
+ }
+}
+
+// ==================== MaxPositionSizeGuard ====================
+
+describe('MaxPositionSizeGuard', () => {
+ it('allows order within limit', () => {
+ const guard = new MaxPositionSizeGuard({ maxPercentOfEquity: 25 })
+ const ctx = makeContext({
+ operation: {
+ action: 'placeOrder',
+ params: { symbol: 'AAPL', side: 'buy', type: 'market', notional: 20_000 },
+ },
+ account: { equity: 100_000 },
+ })
+
+ expect(guard.check(ctx)).toBeNull()
+ })
+
+ it('rejects order exceeding limit', () => {
+ const guard = new MaxPositionSizeGuard({ maxPercentOfEquity: 25 })
+ const ctx = makeContext({
+ operation: {
+ action: 'placeOrder',
+ params: { symbol: 'AAPL', side: 'buy', type: 'market', notional: 30_000 },
+ },
+ account: { equity: 100_000 },
+ })
+
+ const result = guard.check(ctx)
+ expect(result).not.toBeNull()
+ expect(result).toContain('30.0%')
+ expect(result).toContain('limit: 25%')
+ })
+
+ it('considers existing position value', () => {
+ const guard = new MaxPositionSizeGuard({ maxPercentOfEquity: 25 })
+ const ctx = makeContext({
+ operation: {
+ action: 'placeOrder',
+ params: { symbol: 'AAPL', side: 'buy', type: 'market', notional: 10_000 },
+ },
+ positions: [makePosition({ contract: { symbol: 'AAPL' }, marketValue: 20_000 })],
+ account: { equity: 100_000 },
+ })
+
+ const result = guard.check(ctx)
+ expect(result).not.toBeNull()
+ // 20k existing + 10k new = 30k = 30%
+ expect(result).toContain('30.0%')
+ })
+
+ it('uses default 25% if no option provided', () => {
+ const guard = new MaxPositionSizeGuard({})
+ const ctx = makeContext({
+ operation: {
+ action: 'placeOrder',
+ params: { symbol: 'AAPL', side: 'buy', type: 'market', notional: 26_000 },
+ },
+ account: { equity: 100_000 },
+ })
+ expect(guard.check(ctx)).not.toBeNull()
+ })
+
+ it('skips non-placeOrder operations', () => {
+ const guard = new MaxPositionSizeGuard({ maxPercentOfEquity: 1 })
+ const ctx = makeContext({
+ operation: { action: 'closePosition', params: { symbol: 'AAPL' } },
+ })
+ expect(guard.check(ctx)).toBeNull()
+ })
+
+ it('allows when addedValue cannot be estimated (qty-based, no existing position)', () => {
+ const guard = new MaxPositionSizeGuard({ maxPercentOfEquity: 1 })
+ const ctx = makeContext({
+ operation: {
+ action: 'placeOrder',
+ params: { symbol: 'NEW_STOCK', side: 'buy', type: 'market', qty: 100 },
+ },
+ })
+ expect(guard.check(ctx)).toBeNull()
+ })
+})
+
+// ==================== CooldownGuard ====================
+
+describe('CooldownGuard', () => {
+ it('allows first trade', () => {
+ const guard = new CooldownGuard({ minIntervalMs: 60_000 })
+ const ctx = makeContext()
+ expect(guard.check(ctx)).toBeNull()
+ })
+
+ it('rejects rapid repeat trade for same symbol', () => {
+ const guard = new CooldownGuard({ minIntervalMs: 60_000 })
+ const ctx = makeContext()
+
+ guard.check(ctx) // first — allowed
+ const result = guard.check(ctx) // second — rejected
+ expect(result).not.toBeNull()
+ expect(result).toContain('Cooldown active')
+ expect(result).toContain('AAPL')
+ })
+
+ it('allows trade for different symbol', () => {
+ const guard = new CooldownGuard({ minIntervalMs: 60_000 })
+
+ guard.check(makeContext({
+ operation: { action: 'placeOrder', params: { symbol: 'AAPL', side: 'buy', type: 'market', qty: 1 } },
+ }))
+
+ const result = guard.check(makeContext({
+ operation: { action: 'placeOrder', params: { symbol: 'GOOG', side: 'buy', type: 'market', qty: 1 } },
+ }))
+ expect(result).toBeNull()
+ })
+
+ it('skips non-placeOrder operations', () => {
+ const guard = new CooldownGuard({ minIntervalMs: 60_000 })
+ const ctx = makeContext({
+ operation: { action: 'closePosition', params: { symbol: 'AAPL' } },
+ })
+ expect(guard.check(ctx)).toBeNull()
+ })
+})
+
+// ==================== SymbolWhitelistGuard ====================
+
+describe('SymbolWhitelistGuard', () => {
+ it('allows whitelisted symbols', () => {
+ const guard = new SymbolWhitelistGuard({ symbols: ['AAPL', 'GOOG'] })
+ const ctx = makeContext()
+ expect(guard.check(ctx)).toBeNull()
+ })
+
+ it('rejects non-whitelisted symbols', () => {
+ const guard = new SymbolWhitelistGuard({ symbols: ['GOOG'] })
+ const ctx = makeContext()
+ expect(guard.check(ctx)).toContain('not in the allowed list')
+ })
+
+ it('throws on construction without symbols', () => {
+ expect(() => new SymbolWhitelistGuard({})).toThrow('non-empty "symbols"')
+ expect(() => new SymbolWhitelistGuard({ symbols: [] })).toThrow('non-empty "symbols"')
+ })
+
+ it('allows operations without a symbol param', () => {
+ const guard = new SymbolWhitelistGuard({ symbols: ['AAPL'] })
+ const ctx = makeContext({
+ operation: { action: 'cancelOrder', params: { orderId: '123' } },
+ })
+ expect(guard.check(ctx)).toBeNull()
+ })
+})
+
+// ==================== Guard Pipeline ====================
+
+describe('createGuardPipeline', () => {
+ it('returns dispatcher directly when no guards', () => {
+ const dispatcher = vi.fn().mockResolvedValue({ success: true })
+ const account = new MockTradingAccount()
+ const pipeline = createGuardPipeline(dispatcher, account, [])
+
+ // Should be the same function reference
+ expect(pipeline).toBe(dispatcher)
+ })
+
+ it('passes through when all guards allow', async () => {
+ const dispatcher = vi.fn().mockResolvedValue({ success: true })
+ const account = new MockTradingAccount()
+ const allowGuard: OperationGuard = { name: 'allow-all', check: () => null }
+
+ const pipeline = createGuardPipeline(dispatcher, account, [allowGuard])
+ const op: Operation = { action: 'placeOrder', params: { symbol: 'AAPL', side: 'buy', type: 'market', qty: 1 } }
+ const result = await pipeline(op)
+
+ expect(dispatcher).toHaveBeenCalledWith(op)
+ expect(result).toEqual({ success: true })
+ })
+
+ it('blocks when a guard rejects', async () => {
+ const dispatcher = vi.fn().mockResolvedValue({ success: true })
+ const account = new MockTradingAccount()
+ const denyGuard: OperationGuard = { name: 'deny-all', check: () => 'Denied!' }
+
+ const pipeline = createGuardPipeline(dispatcher, account, [denyGuard])
+ const op: Operation = { action: 'placeOrder', params: { symbol: 'AAPL', side: 'buy', type: 'market', qty: 1 } }
+ const result = await pipeline(op) as Record
+
+ expect(dispatcher).not.toHaveBeenCalled()
+ expect(result.success).toBe(false)
+ expect(result.error).toContain('[guard:deny-all]')
+ expect(result.error).toContain('Denied!')
+ })
+
+ it('stops at first rejecting guard', async () => {
+ const dispatcher = vi.fn().mockResolvedValue({ success: true })
+ const account = new MockTradingAccount()
+ const guardA: OperationGuard = { name: 'A', check: vi.fn().mockReturnValue(null) }
+ const guardB: OperationGuard = { name: 'B', check: vi.fn().mockReturnValue('Blocked by B') }
+ const guardC: OperationGuard = { name: 'C', check: vi.fn().mockReturnValue(null) }
+
+ const pipeline = createGuardPipeline(dispatcher, account, [guardA, guardB, guardC])
+ const op: Operation = { action: 'placeOrder', params: { symbol: 'AAPL', side: 'buy', type: 'market', qty: 1 } }
+ await pipeline(op)
+
+ expect(guardA.check).toHaveBeenCalled()
+ expect(guardB.check).toHaveBeenCalled()
+ expect(guardC.check).not.toHaveBeenCalled()
+ })
+
+ it('fetches positions and account info for guard context', async () => {
+ const dispatcher = vi.fn().mockResolvedValue({ success: true })
+ const account = new MockTradingAccount()
+ account.setPositions([makePosition()])
+
+ let capturedCtx: GuardContext | undefined
+ const spyGuard: OperationGuard = {
+ name: 'spy',
+ check: (ctx) => { capturedCtx = ctx; return null },
+ }
+
+ const pipeline = createGuardPipeline(dispatcher, account, [spyGuard])
+ await pipeline({ action: 'placeOrder', params: { symbol: 'AAPL', side: 'buy', type: 'market', qty: 1 } })
+
+ expect(capturedCtx).toBeDefined()
+ expect(capturedCtx!.positions).toHaveLength(1)
+ expect(capturedCtx!.account.equity).toBe(105_000)
+ })
+})
+
+// ==================== Registry ====================
+
+describe('resolveGuards', () => {
+ it('resolves builtin guard types', () => {
+ const guards = resolveGuards([
+ { type: 'max-position-size', options: { maxPercentOfEquity: 25 } },
+ { type: 'symbol-whitelist', options: { symbols: ['AAPL'] } },
+ ])
+ expect(guards).toHaveLength(2)
+ expect(guards[0].name).toBe('max-position-size')
+ expect(guards[1].name).toBe('symbol-whitelist')
+ })
+
+ it('skips unknown guard types with a warning', () => {
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const guards = resolveGuards([{ type: 'nonexistent' }])
+ expect(guards).toHaveLength(0)
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('nonexistent'))
+ warnSpy.mockRestore()
+ })
+
+ it('returns empty for empty config', () => {
+ expect(resolveGuards([])).toEqual([])
+ })
+})
+
+describe('registerGuard', () => {
+ it('registers a custom guard type', () => {
+ registerGuard({
+ type: 'test-custom',
+ create: () => ({ name: 'test-custom', check: () => null }),
+ })
+
+ const guards = resolveGuards([{ type: 'test-custom' }])
+ expect(guards).toHaveLength(1)
+ expect(guards[0].name).toBe('test-custom')
+ })
+})
diff --git a/src/extension/trading/guards/index.ts b/src/extension/trading/guards/index.ts
new file mode 100644
index 00000000..a07e397b
--- /dev/null
+++ b/src/extension/trading/guards/index.ts
@@ -0,0 +1,6 @@
+export { createGuardPipeline } from './guard-pipeline.js'
+export { registerGuard, resolveGuards } from './registry.js'
+export { MaxPositionSizeGuard } from './max-position-size.js'
+export { CooldownGuard } from './cooldown.js'
+export { SymbolWhitelistGuard } from './symbol-whitelist.js'
+export type { GuardContext, OperationGuard, GuardRegistryEntry } from './types.js'
diff --git a/src/extension/trading/guards/max-position-size.ts b/src/extension/trading/guards/max-position-size.ts
new file mode 100644
index 00000000..8b550e54
--- /dev/null
+++ b/src/extension/trading/guards/max-position-size.ts
@@ -0,0 +1,45 @@
+import type { OperationGuard, GuardContext } from './types.js'
+
+const DEFAULT_MAX_PERCENT = 25
+
+export class MaxPositionSizeGuard implements OperationGuard {
+ readonly name = 'max-position-size'
+ private maxPercent: number
+
+ constructor(options: Record) {
+ this.maxPercent = Number(options.maxPercentOfEquity ?? DEFAULT_MAX_PERCENT)
+ }
+
+ check(ctx: GuardContext): string | null {
+ if (ctx.operation.action !== 'placeOrder') return null
+
+ const { positions, account, operation } = ctx
+ const symbol = operation.params.symbol as string
+
+ const existing = positions.find(p => p.contract.symbol === symbol)
+ const currentValue = existing?.marketValue ?? 0
+
+ // Estimate added value — handle both crypto (usd_size/size) and securities (notional/qty) params
+ const dollarAmount = (operation.params.notional ?? operation.params.usd_size) as number | undefined
+ const quantity = (operation.params.qty ?? operation.params.size) as number | undefined
+
+ let addedValue = 0
+ if (dollarAmount) {
+ addedValue = dollarAmount
+ } else if (quantity && existing) {
+ addedValue = quantity * existing.currentPrice
+ }
+ // If we can't estimate (new symbol + qty-based without existing position), allow — broker will validate
+
+ if (addedValue === 0) return null
+
+ const projectedValue = currentValue + addedValue
+ const percent = account.equity > 0 ? (projectedValue / account.equity) * 100 : 0
+
+ if (percent > this.maxPercent) {
+ return `Position for ${symbol} would be ${percent.toFixed(1)}% of equity (limit: ${this.maxPercent}%)`
+ }
+
+ return null
+ }
+}
diff --git a/src/extension/crypto-trading/guards/registry.ts b/src/extension/trading/guards/registry.ts
similarity index 58%
rename from src/extension/crypto-trading/guards/registry.ts
rename to src/extension/trading/guards/registry.ts
index 6b4fc6ec..792e6073 100644
--- a/src/extension/crypto-trading/guards/registry.ts
+++ b/src/extension/trading/guards/registry.ts
@@ -1,37 +1,35 @@
-import type { OperationGuard, GuardRegistryEntry } from './types.js';
-import { MaxPositionSizeGuard } from './max-position-size.js';
-import { MaxLeverageGuard } from './max-leverage.js';
-import { CooldownGuard } from './cooldown.js';
-import { SymbolWhitelistGuard } from './symbol-whitelist.js';
+import type { OperationGuard, GuardRegistryEntry } from './types.js'
+import { MaxPositionSizeGuard } from './max-position-size.js'
+import { CooldownGuard } from './cooldown.js'
+import { SymbolWhitelistGuard } from './symbol-whitelist.js'
const builtinGuards: GuardRegistryEntry[] = [
{ type: 'max-position-size', create: (opts) => new MaxPositionSizeGuard(opts) },
- { type: 'max-leverage', create: (opts) => new MaxLeverageGuard(opts) },
{ type: 'cooldown', create: (opts) => new CooldownGuard(opts) },
{ type: 'symbol-whitelist', create: (opts) => new SymbolWhitelistGuard(opts) },
-];
+]
const registry = new Map(
builtinGuards.map(g => [g.type, g.create]),
-);
+)
-/** Register a custom guard type (for third-party extensions) */
+/** Register a custom guard type (for third-party extensions). */
export function registerGuard(entry: GuardRegistryEntry): void {
- registry.set(entry.type, entry.create);
+ registry.set(entry.type, entry.create)
}
-/** Resolve config entries into guard instances via the registry */
+/** Resolve config entries into guard instances via the registry. */
export function resolveGuards(
configs: Array<{ type: string; options?: Record }>,
): OperationGuard[] {
- const guards: OperationGuard[] = [];
+ const guards: OperationGuard[] = []
for (const cfg of configs) {
- const factory = registry.get(cfg.type);
+ const factory = registry.get(cfg.type)
if (!factory) {
- console.warn(`guard: unknown type "${cfg.type}", skipped`);
- continue;
+ console.warn(`guard: unknown type "${cfg.type}", skipped`)
+ continue
}
- guards.push(factory(cfg.options ?? {}));
+ guards.push(factory(cfg.options ?? {}))
}
- return guards;
+ return guards
}
diff --git a/src/extension/crypto-trading/guards/symbol-whitelist.ts b/src/extension/trading/guards/symbol-whitelist.ts
similarity index 50%
rename from src/extension/crypto-trading/guards/symbol-whitelist.ts
rename to src/extension/trading/guards/symbol-whitelist.ts
index f284f81f..8b34ec48 100644
--- a/src/extension/crypto-trading/guards/symbol-whitelist.ts
+++ b/src/extension/trading/guards/symbol-whitelist.ts
@@ -1,24 +1,24 @@
-import type { OperationGuard, GuardContext } from './types.js';
+import type { OperationGuard, GuardContext } from './types.js'
export class SymbolWhitelistGuard implements OperationGuard {
- readonly name = 'symbol-whitelist';
- private allowed: Set;
+ readonly name = 'symbol-whitelist'
+ private allowed: Set
constructor(options: Record) {
- const symbols = options.symbols as string[] | undefined;
+ const symbols = options.symbols as string[] | undefined
if (!symbols || symbols.length === 0) {
- throw new Error('symbol-whitelist guard requires a non-empty "symbols" array in options');
+ throw new Error('symbol-whitelist guard requires a non-empty "symbols" array in options')
}
- this.allowed = new Set(symbols);
+ this.allowed = new Set(symbols)
}
check(ctx: GuardContext): string | null {
- const symbol = ctx.operation.params.symbol as string | undefined;
- if (!symbol) return null;
+ const symbol = ctx.operation.params.symbol as string | undefined
+ if (!symbol) return null
if (!this.allowed.has(symbol)) {
- return `Symbol ${symbol} is not in the allowed list`;
+ return `Symbol ${symbol} is not in the allowed list`
}
- return null;
+ return null
}
}
diff --git a/src/extension/trading/guards/types.ts b/src/extension/trading/guards/types.ts
new file mode 100644
index 00000000..7869dc85
--- /dev/null
+++ b/src/extension/trading/guards/types.ts
@@ -0,0 +1,21 @@
+import type { Operation } from '../git/types.js'
+import type { Position, AccountInfo } from '../interfaces.js'
+
+/** Read-only context assembled by the pipeline, consumed by guards. */
+export interface GuardContext {
+ readonly operation: Operation
+ readonly positions: readonly Position[]
+ readonly account: Readonly
+}
+
+/** A guard that can reject operations. Returns null to allow, or a rejection reason string. */
+export interface OperationGuard {
+ readonly name: string
+ check(ctx: GuardContext): Promise | string | null
+}
+
+/** Registry entry: type identifier + factory function. */
+export interface GuardRegistryEntry {
+ type: string
+ create(options: Record): OperationGuard
+}
diff --git a/src/extension/trading/index.ts b/src/extension/trading/index.ts
new file mode 100644
index 00000000..650d6b74
--- /dev/null
+++ b/src/extension/trading/index.ts
@@ -0,0 +1,107 @@
+// Contract
+export type {
+ Contract,
+ SecType,
+ OptionType,
+ ComboLeg,
+ DeltaNeutralContract,
+} from './contract.js'
+
+// Interfaces
+export type {
+ Position,
+ OrderRequest,
+ OrderResult,
+ Order,
+ AccountInfo,
+ Quote,
+ FundingRate,
+ OrderBookLevel,
+ OrderBook,
+ MarketClock,
+ AccountCapabilities,
+ ITradingAccount,
+ WalletState,
+} from './interfaces.js'
+
+// AccountManager
+export { AccountManager } from './account-manager.js'
+export type {
+ AccountEntry,
+ AccountSummary,
+ AggregatedEquity,
+ ContractSearchResult,
+} from './account-manager.js'
+
+// Trading-as-Git
+export { TradingGit } from './git/index.js'
+export type {
+ ITradingGit,
+ TradingGitConfig,
+ CommitHash,
+ Operation,
+ OperationAction,
+ OperationResult,
+ OperationStatus,
+ AddResult,
+ CommitPrepareResult,
+ PushResult,
+ GitStatus,
+ GitCommit,
+ GitState,
+ CommitLogEntry,
+ GitExportState,
+ OperationSummary,
+ OrderStatusUpdate,
+ SyncResult,
+ PriceChangeInput,
+ SimulatePriceChangeResult,
+} from './git/index.js'
+
+// Guards
+export {
+ createGuardPipeline,
+ registerGuard,
+ resolveGuards,
+ MaxPositionSizeGuard,
+ CooldownGuard,
+ SymbolWhitelistGuard,
+} from './guards/index.js'
+export type {
+ GuardContext,
+ OperationGuard,
+ GuardRegistryEntry,
+} from './guards/index.js'
+
+// Operation Dispatcher
+export { createOperationDispatcher } from './operation-dispatcher.js'
+
+// Wallet State Bridge
+export { createWalletStateBridge } from './wallet-state-bridge.js'
+
+// Platform
+export type { IPlatform, PlatformCredentials } from './platform.js'
+export { CcxtPlatform } from './providers/ccxt/CcxtPlatform.js'
+export type { CcxtPlatformConfig } from './providers/ccxt/CcxtPlatform.js'
+export { AlpacaPlatform } from './providers/alpaca/AlpacaPlatform.js'
+export type { AlpacaPlatformConfig } from './providers/alpaca/AlpacaPlatform.js'
+export {
+ createPlatformFromConfig,
+ createAccountFromConfig,
+ validatePlatformRefs,
+} from './platform-factory.js'
+
+// Factory (wiring)
+export { wireAccountTrading } from './factory.js'
+export type { AccountSetup } from './factory.js'
+
+// Unified Tool Factory
+export { createTradingTools, resolveAccounts, resolveOne } from './adapter.js'
+export type { AccountResolver, ResolvedAccount } from './adapter.js'
+
+// Providers
+export { AlpacaAccount } from './providers/alpaca/index.js'
+export type { AlpacaAccountConfig } from './providers/alpaca/index.js'
+export { CcxtAccount } from './providers/ccxt/index.js'
+export { createCcxtProviderTools } from './providers/ccxt/index.js'
+export type { CcxtAccountConfig } from './providers/ccxt/index.js'
diff --git a/src/extension/trading/interfaces.ts b/src/extension/trading/interfaces.ts
new file mode 100644
index 00000000..94da3628
--- /dev/null
+++ b/src/extension/trading/interfaces.ts
@@ -0,0 +1,208 @@
+/**
+ * Unified Trading interfaces — IBKR-style Account model
+ *
+ * Merges the concepts from crypto-trading (ICryptoTradingEngine) and
+ * securities-trading (ISecuritiesTradingEngine) into a single Account interface.
+ * All providers (Alpaca, CCXT, IBKR, ...) implement ITradingAccount.
+ */
+
+import type { Contract, SecType, ContractDescription, ContractDetails } from './contract.js'
+
+// ==================== Position ====================
+
+/**
+ * Unified position/holding.
+ * Stocks are the special case: side='long', leverage=1, no margin/liquidation.
+ */
+export interface Position {
+ contract: Contract
+ side: 'long' | 'short'
+ qty: number
+ avgEntryPrice: number
+ currentPrice: number
+ marketValue: number
+ unrealizedPnL: number
+ unrealizedPnLPercent: number
+ costBasis: number
+ leverage: number
+ margin?: number
+ liquidationPrice?: number
+}
+
+// ==================== Orders ====================
+
+/** IBKR-aligned order types. Providers return error for unsupported types. */
+export type OrderType =
+ | 'market'
+ | 'limit'
+ | 'stop'
+ | 'stop_limit'
+ | 'trailing_stop'
+ | 'trailing_stop_limit'
+ | 'moc'
+
+/** IBKR-aligned time-in-force values. */
+export type TimeInForce = 'day' | 'gtc' | 'ioc' | 'fok' | 'opg' | 'gtd'
+
+export interface OrderRequest {
+ contract: Contract
+ side: 'buy' | 'sell'
+ type: OrderType
+ qty?: number
+ notional?: number
+ price?: number // limit price (IBKR: lmtPrice)
+ stopPrice?: number // stop trigger price (IBKR: auxPrice for STP)
+ trailingAmount?: number // trailing stop absolute offset (IBKR: auxPrice for TRAIL)
+ trailingPercent?: number // trailing stop percentage
+ reduceOnly?: boolean
+ timeInForce?: TimeInForce
+ goodTillDate?: string // ISO date for GTD orders
+ extendedHours?: boolean // IBKR: outsideRth
+ parentId?: string // bracket order: child references parent
+ ocaGroup?: string // One-Cancels-All group name
+}
+
+export interface OrderResult {
+ success: boolean
+ orderId?: string
+ error?: string
+ message?: string
+ filledPrice?: number
+ filledQty?: number
+}
+
+export interface Order {
+ id: string
+ contract: Contract
+ side: 'buy' | 'sell'
+ type: OrderType
+ qty: number
+ price?: number
+ stopPrice?: number
+ trailingAmount?: number
+ trailingPercent?: number
+ reduceOnly?: boolean
+ timeInForce?: TimeInForce
+ goodTillDate?: string
+ extendedHours?: boolean
+ parentId?: string
+ ocaGroup?: string
+ status: 'pending' | 'filled' | 'cancelled' | 'rejected' | 'partially_filled'
+ filledPrice?: number
+ filledQty?: number
+ filledAt?: Date
+ createdAt: Date
+ rejectReason?: string
+}
+
+// ==================== Account info ====================
+
+export interface AccountInfo {
+ cash: number
+ equity: number
+ unrealizedPnL: number
+ realizedPnL: number
+ portfolioValue?: number
+ buyingPower?: number
+ totalMargin?: number
+ dayTradeCount?: number
+ dayTradingBuyingPower?: number
+}
+
+// ==================== Market data ====================
+
+export interface Quote {
+ contract: Contract
+ last: number
+ bid: number
+ ask: number
+ volume: number
+ high?: number
+ low?: number
+ timestamp: Date
+}
+
+export interface FundingRate {
+ contract: Contract
+ fundingRate: number
+ nextFundingTime?: Date
+ previousFundingRate?: number
+ timestamp: Date
+}
+
+/** [price, amount] */
+export type OrderBookLevel = [price: number, amount: number]
+
+export interface OrderBook {
+ contract: Contract
+ bids: OrderBookLevel[]
+ asks: OrderBookLevel[]
+ timestamp: Date
+}
+
+export interface MarketClock {
+ isOpen: boolean
+ nextOpen?: Date
+ nextClose?: Date
+ timestamp?: Date
+}
+
+// ==================== Account capabilities ====================
+
+export interface AccountCapabilities {
+ supportedSecTypes: SecType[]
+ supportedOrderTypes: OrderType[]
+}
+
+// ==================== ITradingAccount ====================
+
+export interface ITradingAccount {
+ /** Unique account ID, e.g. "alpaca-paper", "bybit-main". */
+ readonly id: string
+
+ /** Provider name, e.g. "alpaca", "ccxt". */
+ readonly provider: string
+
+ /** User-facing display name. */
+ readonly label: string
+
+ // ---- Lifecycle ----
+
+ init(): Promise
+ close(): Promise
+
+ // ---- Contract search (IBKR: reqMatchingSymbols + reqContractDetails) ----
+
+ searchContracts(pattern: string): Promise
+ getContractDetails(query: Partial): Promise
+
+ // ---- Trading operations ----
+
+ placeOrder(order: OrderRequest): Promise
+ modifyOrder(orderId: string, changes: Partial): Promise
+ cancelOrder(orderId: string): Promise
+ closePosition(contract: Contract, qty?: number): Promise
+
+ // ---- Queries ----
+
+ getAccount(): Promise
+ getPositions(): Promise
+ getOrders(): Promise
+ getQuote(contract: Contract): Promise
+ getMarketClock(): Promise
+
+ // ---- Capabilities ----
+
+ getCapabilities(): AccountCapabilities
+}
+
+// ==================== Wallet state ====================
+
+export interface WalletState {
+ cash: number
+ equity: number
+ unrealizedPnL: number
+ realizedPnL: number
+ positions: Position[]
+ pendingOrders: Order[]
+}
diff --git a/src/extension/trading/operation-dispatcher.spec.ts b/src/extension/trading/operation-dispatcher.spec.ts
new file mode 100644
index 00000000..bcceed46
--- /dev/null
+++ b/src/extension/trading/operation-dispatcher.spec.ts
@@ -0,0 +1,243 @@
+import { describe, it, expect, beforeEach } from 'vitest'
+import { createOperationDispatcher } from './operation-dispatcher.js'
+import { MockTradingAccount, makeOrderResult } from './__test__/mock-account.js'
+import type { Operation } from './git/types.js'
+
+describe('createOperationDispatcher', () => {
+ let account: MockTradingAccount
+ let dispatch: (op: Operation) => Promise
+
+ beforeEach(() => {
+ account = new MockTradingAccount()
+ dispatch = createOperationDispatcher(account)
+ })
+
+ // ==================== placeOrder ====================
+
+ describe('placeOrder', () => {
+ it('calls account.placeOrder with constructed contract and order params', async () => {
+ const op: Operation = {
+ action: 'placeOrder',
+ params: {
+ symbol: 'AAPL',
+ side: 'buy',
+ type: 'market',
+ qty: 10,
+ timeInForce: 'day',
+ },
+ }
+
+ const result = await dispatch(op) as Record
+
+ expect(account.placeOrder).toHaveBeenCalledTimes(1)
+ const call = account.placeOrder.mock.calls[0][0]
+ expect(call.contract.symbol).toBe('AAPL')
+ expect(call.side).toBe('buy')
+ expect(call.type).toBe('market')
+ expect(call.qty).toBe(10)
+ expect(result.success).toBe(true)
+ })
+
+ it('passes aliceId and extra contract fields', async () => {
+ const op: Operation = {
+ action: 'placeOrder',
+ params: {
+ aliceId: 'alpaca-AAPL',
+ symbol: 'AAPL',
+ secType: 'STK',
+ currency: 'USD',
+ exchange: 'NASDAQ',
+ side: 'buy',
+ type: 'limit',
+ qty: 5,
+ price: 150,
+ },
+ }
+
+ await dispatch(op)
+
+ const call = account.placeOrder.mock.calls[0][0]
+ expect(call.contract.aliceId).toBe('alpaca-AAPL')
+ expect(call.contract.secType).toBe('STK')
+ expect(call.contract.currency).toBe('USD')
+ expect(call.contract.exchange).toBe('NASDAQ')
+ expect(call.price).toBe(150)
+ })
+
+ it('returns order info on success with filled status', async () => {
+ account.placeOrder.mockResolvedValue(makeOrderResult({
+ orderId: 'ord-123',
+ filledPrice: 155,
+ filledQty: 10,
+ }))
+
+ const op: Operation = {
+ action: 'placeOrder',
+ params: { symbol: 'AAPL', side: 'buy', type: 'market', qty: 10 },
+ }
+
+ const result = await dispatch(op) as Record
+ expect(result.success).toBe(true)
+ const order = result.order as Record
+ expect(order.id).toBe('ord-123')
+ expect(order.status).toBe('filled')
+ expect(order.filledPrice).toBe(155)
+ })
+
+ it('returns pending status when no filledPrice', async () => {
+ account.placeOrder.mockResolvedValue(makeOrderResult({
+ orderId: 'ord-456',
+ filledPrice: undefined,
+ filledQty: undefined,
+ }))
+
+ const op: Operation = {
+ action: 'placeOrder',
+ params: { symbol: 'AAPL', side: 'buy', type: 'limit', qty: 10, price: 140 },
+ }
+
+ const result = await dispatch(op) as Record
+ const order = result.order as Record
+ expect(order.status).toBe('pending')
+ })
+
+ it('returns error on failure', async () => {
+ account.placeOrder.mockResolvedValue({ success: false, error: 'Insufficient funds' })
+
+ const op: Operation = {
+ action: 'placeOrder',
+ params: { symbol: 'AAPL', side: 'buy', type: 'market', qty: 10 },
+ }
+
+ const result = await dispatch(op) as Record