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 + expect(result.success).toBe(false) + expect(result.error).toBe('Insufficient funds') + expect(result.order).toBeUndefined() + }) + }) + + // ==================== closePosition ==================== + + describe('closePosition', () => { + it('calls account.closePosition with contract and optional qty', async () => { + const op: Operation = { + action: 'closePosition', + params: { symbol: 'AAPL', qty: 5 }, + } + + await dispatch(op) + + expect(account.closePosition).toHaveBeenCalledTimes(1) + const [contract, qty] = account.closePosition.mock.calls[0] + expect(contract.symbol).toBe('AAPL') + expect(qty).toBe(5) + }) + + it('passes undefined qty for full close', async () => { + const op: Operation = { + action: 'closePosition', + params: { symbol: 'AAPL' }, + } + + await dispatch(op) + + const [, qty] = account.closePosition.mock.calls[0] + expect(qty).toBeUndefined() + }) + }) + + // ==================== cancelOrder ==================== + + describe('cancelOrder', () => { + it('calls account.cancelOrder and returns success', async () => { + const op: Operation = { + action: 'cancelOrder', + params: { orderId: 'ord-789' }, + } + + const result = await dispatch(op) as Record + + expect(account.cancelOrder).toHaveBeenCalledWith('ord-789') + expect(result.success).toBe(true) + }) + + it('returns error message on cancel failure', async () => { + account.cancelOrder.mockResolvedValue(false) + + const op: Operation = { + action: 'cancelOrder', + params: { orderId: 'ord-789' }, + } + + const result = await dispatch(op) as Record + expect(result.success).toBe(false) + expect(result.error).toContain('Failed to cancel') + }) + }) + + // ==================== modifyOrder ==================== + + describe('modifyOrder', () => { + it('calls account.modifyOrder with orderId and changes', async () => { + const op: Operation = { + action: 'modifyOrder', + params: { orderId: 'ord-123', price: 155, qty: 20 }, + } + + const result = await dispatch(op) as Record + + expect(account.modifyOrder).toHaveBeenCalledTimes(1) + const [orderId, changes] = account.modifyOrder.mock.calls[0] + expect(orderId).toBe('ord-123') + expect(changes.price).toBe(155) + expect(changes.qty).toBe(20) + expect(result.success).toBe(true) + }) + + it('returns order info on success', async () => { + account.modifyOrder.mockResolvedValue(makeOrderResult({ + orderId: 'ord-123', + filledPrice: undefined, + filledQty: undefined, + })) + + const op: Operation = { + action: 'modifyOrder', + params: { orderId: 'ord-123', price: 160 }, + } + + 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('pending') + }) + + it('returns error on failure', async () => { + account.modifyOrder.mockResolvedValue({ success: false, error: 'Order not found' }) + + const op: Operation = { + action: 'modifyOrder', + params: { orderId: 'ord-999', price: 100 }, + } + + const result = await dispatch(op) as Record + expect(result.success).toBe(false) + expect(result.error).toBe('Order not found') + expect(result.order).toBeUndefined() + }) + }) + + // ==================== unknown action ==================== + + describe('unknown action', () => { + it('throws for unknown operation action', async () => { + const op: Operation = { + action: 'syncOrders' as never, + params: {}, + } + + await expect(dispatch(op)).rejects.toThrow('Unknown operation action') + }) + }) +}) diff --git a/src/extension/trading/operation-dispatcher.ts b/src/extension/trading/operation-dispatcher.ts new file mode 100644 index 00000000..a3b04e62 --- /dev/null +++ b/src/extension/trading/operation-dispatcher.ts @@ -0,0 +1,120 @@ +/** + * Unified Operation Dispatcher + * + * Bridges TradingGit's Operation → ITradingAccount method calls. + * Used as the TradingGitConfig.executeOperation callback. + * + * Return values match the structure expected by TradingGit.parseOperationResult: + * - placeOrder/modifyOrder/closePosition: { success, order?: { id, status, filledPrice, filledQty } } + * - cancelOrder: { success, error? } + */ + +import type { Contract } from './contract.js' +import type { ITradingAccount, OrderType, TimeInForce, OrderRequest } from './interfaces.js' +import type { Operation } from './git/types.js' + +export function createOperationDispatcher(account: ITradingAccount) { + return async (op: Operation): Promise => { + switch (op.action) { + case 'placeOrder': { + const contract: Partial = {} + if (op.params.aliceId) contract.aliceId = op.params.aliceId as string + if (op.params.symbol) contract.symbol = op.params.symbol as string + if (op.params.secType) contract.secType = op.params.secType as Contract['secType'] + if (op.params.currency) contract.currency = op.params.currency as string + if (op.params.exchange) contract.exchange = op.params.exchange as string + + const result = await account.placeOrder({ + contract: contract as Contract, + side: op.params.side as 'buy' | 'sell', + type: op.params.type as OrderType, + 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, + trailingAmount: op.params.trailingAmount as number | undefined, + trailingPercent: op.params.trailingPercent as number | undefined, + reduceOnly: op.params.reduceOnly as boolean | undefined, + timeInForce: (op.params.timeInForce as TimeInForce) ?? 'day', + goodTillDate: op.params.goodTillDate as string | undefined, + extendedHours: op.params.extendedHours as boolean | undefined, + parentId: op.params.parentId as string | undefined, + ocaGroup: op.params.ocaGroup as string | undefined, + }) + + 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 'modifyOrder': { + const orderId = op.params.orderId as string + const changes: Partial = {} + if (op.params.qty != null) changes.qty = op.params.qty as number + if (op.params.price != null) changes.price = op.params.price as number + if (op.params.stopPrice != null) changes.stopPrice = op.params.stopPrice as number + if (op.params.trailingAmount != null) changes.trailingAmount = op.params.trailingAmount as number + if (op.params.trailingPercent != null) changes.trailingPercent = op.params.trailingPercent as number + if (op.params.type) changes.type = op.params.type as OrderType + if (op.params.timeInForce) changes.timeInForce = op.params.timeInForce as TimeInForce + if (op.params.goodTillDate) changes.goodTillDate = op.params.goodTillDate as string + + const result = await account.modifyOrder(orderId, changes) + + 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 contract: Partial = {} + if (op.params.aliceId) contract.aliceId = op.params.aliceId as string + if (op.params.symbol) contract.symbol = op.params.symbol as string + if (op.params.secType) contract.secType = op.params.secType as Contract['secType'] + + const qty = op.params.qty as number | undefined + const result = await account.closePosition(contract as Contract, qty) + + 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 account.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/trading/platform-factory.ts b/src/extension/trading/platform-factory.ts new file mode 100644 index 00000000..a7e56cb0 --- /dev/null +++ b/src/extension/trading/platform-factory.ts @@ -0,0 +1,62 @@ +/** + * Platform Factory — creates IPlatform and ITradingAccount from config. + */ + +import type { IPlatform, PlatformCredentials } from './platform.js' +import type { ITradingAccount } from './interfaces.js' +import { CcxtPlatform } from './providers/ccxt/CcxtPlatform.js' +import { AlpacaPlatform } from './providers/alpaca/AlpacaPlatform.js' +import type { PlatformConfig, AccountConfig } from '../../core/config.js' + +/** Create an IPlatform from a parsed PlatformConfig. */ +export function createPlatformFromConfig(config: PlatformConfig): IPlatform { + switch (config.type) { + case 'ccxt': + return new CcxtPlatform({ + id: config.id, + label: config.label, + exchange: config.exchange, + sandbox: config.sandbox, + demoTrading: config.demoTrading, + defaultMarketType: config.defaultMarketType, + options: config.options, + }) + case 'alpaca': + return new AlpacaPlatform({ + id: config.id, + label: config.label, + paper: config.paper, + }) + } +} + +/** Create an ITradingAccount from a platform + account config. */ +export function createAccountFromConfig( + platform: IPlatform, + accountConfig: AccountConfig, +): ITradingAccount { + const credentials: PlatformCredentials = { + id: accountConfig.id, + label: accountConfig.label, + apiKey: accountConfig.apiKey, + apiSecret: accountConfig.apiSecret, + password: accountConfig.password, + } + return platform.createAccount(credentials) +} + +/** Validate that all account platformId references resolve to a known platform. */ +export function validatePlatformRefs( + platforms: IPlatform[], + accounts: AccountConfig[], +): void { + const platformIds = new Set(platforms.map((p) => p.id)) + for (const acc of accounts) { + if (!platformIds.has(acc.platformId)) { + throw new Error( + `Account "${acc.id}" references unknown platformId "${acc.platformId}". ` + + `Available platforms: ${[...platformIds].join(', ')}`, + ) + } + } +} diff --git a/src/extension/trading/platform.ts b/src/extension/trading/platform.ts new file mode 100644 index 00000000..c1963fd3 --- /dev/null +++ b/src/extension/trading/platform.ts @@ -0,0 +1,36 @@ +/** + * Platform — structural definition for a broker/exchange configuration. + * + * A platform defines HOW to connect (exchange type, market mode, sandbox settings). + * Multiple accounts can share one platform, each with individual credentials. + * The platform acts as a typed factory for accounts. + */ + +import type { ITradingAccount } from './interfaces.js' + +/** Credentials passed to IPlatform.createAccount(). */ +export interface PlatformCredentials { + id: string + label?: string + apiKey?: string + apiSecret?: string + password?: string +} + +export interface IPlatform { + /** Unique platform id, e.g. "bybit-swap", "alpaca-paper". */ + readonly id: string + + /** Human-readable name, e.g. "Bybit USDT Perps". */ + readonly label: string + + /** + * Provider class tag. Matches ITradingAccount.provider on created accounts. + * CcxtPlatform → exchange name (e.g. "bybit"). + * AlpacaPlatform → "alpaca". + */ + readonly providerType: string + + /** Create a new ITradingAccount instance from per-account credentials. */ + createAccount(credentials: PlatformCredentials): ITradingAccount +} diff --git a/src/extension/trading/providers/alpaca/AlpacaAccount.spec.ts b/src/extension/trading/providers/alpaca/AlpacaAccount.spec.ts new file mode 100644 index 00000000..2969c107 --- /dev/null +++ b/src/extension/trading/providers/alpaca/AlpacaAccount.spec.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from 'vitest' +import { computeRealizedPnL } from './alpaca-pnl.js' + +/** Helper to build a fill activity record. */ +function fill(symbol: string, side: 'buy' | 'sell', qty: number, price: number, index = 0) { + return { + activity_type: 'FILL' as const, + symbol, + side, + qty: String(qty), + price: String(price), + cum_qty: String(qty), + leaves_qty: '0', + transaction_time: `2025-01-01T00:00:0${index}Z`, + order_id: `order-${index}`, + type: 'fill', + } +} + +describe('computeRealizedPnL', () => { + it('returns 0 for empty fills', () => { + expect(computeRealizedPnL([])).toBe(0) + }) + + it('returns 0 when only buys (no closes)', () => { + const fills = [ + fill('AAPL', 'buy', 10, 150, 0), + fill('GOOG', 'buy', 5, 2800, 1), + ] + expect(computeRealizedPnL(fills)).toBe(0) + }) + + it('computes profit on simple buy then sell', () => { + const fills = [ + fill('AAPL', 'buy', 10, 150, 0), + fill('AAPL', 'sell', 10, 160, 1), + ] + // (160 - 150) * 10 = 100 + expect(computeRealizedPnL(fills)).toBe(100) + }) + + it('computes loss on simple buy then sell', () => { + const fills = [ + fill('AAPL', 'buy', 10, 150, 0), + fill('AAPL', 'sell', 10, 140, 1), + ] + // (140 - 150) * 10 = -100 + expect(computeRealizedPnL(fills)).toBe(-100) + }) + + it('handles partial close (sell less than bought)', () => { + const fills = [ + fill('AAPL', 'buy', 10, 150, 0), + fill('AAPL', 'sell', 4, 160, 1), + ] + // (160 - 150) * 4 = 40 + expect(computeRealizedPnL(fills)).toBe(40) + }) + + it('handles FIFO across multiple buy lots', () => { + const fills = [ + fill('AAPL', 'buy', 5, 100, 0), + fill('AAPL', 'buy', 5, 120, 1), + fill('AAPL', 'sell', 7, 130, 2), + ] + // FIFO: first lot 5@100 → (130-100)*5 = 150 + // second lot 2@120 → (130-120)*2 = 20 + // total = 170 + expect(computeRealizedPnL(fills)).toBe(170) + }) + + it('handles multiple symbols independently', () => { + const fills = [ + fill('AAPL', 'buy', 10, 150, 0), + fill('GOOG', 'buy', 2, 2800, 1), + fill('AAPL', 'sell', 10, 160, 2), + fill('GOOG', 'sell', 2, 2700, 3), + ] + // AAPL: (160-150)*10 = 100 + // GOOG: (2700-2800)*2 = -200 + // total = -100 + expect(computeRealizedPnL(fills)).toBe(-100) + }) + + it('handles short selling (sell then buy)', () => { + const fills = [ + fill('AAPL', 'sell', 10, 160, 0), + fill('AAPL', 'buy', 10, 150, 1), + ] + // Short: entry 160, exit 150 → (160-150)*10 = 100 profit + expect(computeRealizedPnL(fills)).toBe(100) + }) + + it('handles short selling at a loss', () => { + const fills = [ + fill('AAPL', 'sell', 10, 150, 0), + fill('AAPL', 'buy', 10, 160, 1), + ] + // Short: entry 150, exit 160 → (150-160)*10 = -100 loss + expect(computeRealizedPnL(fills)).toBe(-100) + }) + + it('handles multiple round trips', () => { + const fills = [ + fill('AAPL', 'buy', 10, 100, 0), + fill('AAPL', 'sell', 10, 110, 1), + fill('AAPL', 'buy', 10, 105, 2), + fill('AAPL', 'sell', 10, 115, 3), + ] + // Trip 1: (110-100)*10 = 100 + // Trip 2: (115-105)*10 = 100 + // total = 200 + expect(computeRealizedPnL(fills)).toBe(200) + }) + + it('rounds to cents', () => { + const fills = [ + fill('AAPL', 'buy', 3, 10.333, 0), + fill('AAPL', 'sell', 3, 10.667, 1), + ] + // (10.667 - 10.333) * 3 = 1.002 + expect(computeRealizedPnL(fills)).toBe(1) + }) +}) diff --git a/src/extension/trading/providers/alpaca/AlpacaAccount.ts b/src/extension/trading/providers/alpaca/AlpacaAccount.ts new file mode 100644 index 00000000..4bbc942d --- /dev/null +++ b/src/extension/trading/providers/alpaca/AlpacaAccount.ts @@ -0,0 +1,384 @@ +/** + * AlpacaAccount — ITradingAccount adapter for Alpaca + * + * Direct implementation against @alpacahq/alpaca-trade-api SDK. + * Supports US equities (STK). Contract resolution uses Alpaca's ticker + * as nativeId — unambiguous for stocks, extensible when options arrive. + */ + +import Alpaca from '@alpacahq/alpaca-trade-api' +import type { Contract, ContractDescription, ContractDetails } from '../../contract.js' +import type { + ITradingAccount, + AccountCapabilities, + AccountInfo, + Position, + Order, + OrderRequest, + OrderResult, + Quote, + MarketClock, +} from '../../interfaces.js' +import type { + AlpacaAccountConfig, + AlpacaAccountRaw, + AlpacaPositionRaw, + AlpacaOrderRaw, + AlpacaSnapshotRaw, + AlpacaFillActivityRaw, + AlpacaClockRaw, +} from './alpaca-types.js' +import { makeContract, resolveSymbol, mapAlpacaOrderStatus } from './alpaca-contracts.js' +import { computeRealizedPnL } from './alpaca-pnl.js' + +export class AlpacaAccount implements ITradingAccount { + readonly id: string + readonly provider = 'alpaca' + readonly label: string + + private client!: InstanceType + private readonly config: AlpacaAccountConfig + + /** Cached realized PnL from FILL activities (FIFO lot matching) */ + private realizedPnLCache: { value: number; updatedAt: number } | null = null + private static readonly REALIZED_PNL_TTL_MS = 60_000 + + constructor(config: AlpacaAccountConfig) { + this.config = config + this.id = config.id ?? (config.paper ? 'alpaca-paper' : 'alpaca-live') + this.label = config.label ?? (config.paper ? 'Alpaca Paper' : 'Alpaca Live') + } + + // ---- Lifecycle ---- + + private static readonly MAX_INIT_RETRIES = 5 + private static readonly INIT_RETRY_BASE_MS = 1000 + + async init(): Promise { + this.client = new Alpaca({ + keyId: this.config.apiKey, + secretKey: this.config.secretKey, + paper: this.config.paper, + }) + + let lastErr: unknown + for (let attempt = 1; attempt <= AlpacaAccount.MAX_INIT_RETRIES; attempt++) { + try { + const account = await this.client.getAccount() as AlpacaAccountRaw + console.log( + `AlpacaAccount[${this.id}]: connected (paper=${this.config.paper}, equity=$${parseFloat(account.equity).toFixed(2)})`, + ) + return + } catch (err) { + lastErr = err + if (attempt < AlpacaAccount.MAX_INIT_RETRIES) { + const delay = AlpacaAccount.INIT_RETRY_BASE_MS * 2 ** (attempt - 1) + console.warn(`AlpacaAccount[${this.id}]: init attempt ${attempt}/${AlpacaAccount.MAX_INIT_RETRIES} failed, retrying in ${delay}ms...`) + await new Promise(r => setTimeout(r, delay)) + } + } + } + throw lastErr + } + + async close(): Promise { + // Alpaca SDK has no explicit close + } + + // ---- Contract search ---- + + async searchContracts(pattern: string): Promise { + if (!pattern) return [] + + // Alpaca tickers are unique for stocks — pattern is treated as exact ticker match + const ticker = pattern.toUpperCase() + return [{ contract: makeContract(ticker, this.provider) }] + } + + async getContractDetails(query: Partial): Promise { + const symbol = resolveSymbol(query as Contract, this.provider) + if (!symbol) return null + + return { + contract: makeContract(symbol, this.provider), + validExchanges: ['SMART', 'NYSE', 'NASDAQ', 'ARCA'], + orderTypes: ['market', 'limit', 'stop', 'stop_limit', 'trailing_stop'], + stockType: 'COMMON', + } + } + + // ---- Trading operations ---- + + async placeOrder(order: OrderRequest): Promise { + const symbol = resolveSymbol(order.contract, this.provider) + if (!symbol) { + return { success: false, error: 'Cannot resolve contract to Alpaca symbol' } + } + + try { + const alpacaOrder: Record = { + symbol, + side: order.side, + type: order.type === 'trailing_stop' ? 'trailing_stop' : order.type, + time_in_force: order.timeInForce ?? 'day', + } + + 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.trailingAmount != null) alpacaOrder.trail_price = order.trailingAmount + if (order.trailingPercent != null) alpacaOrder.trail_percent = order.trailingPercent + 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 modifyOrder(orderId: string, changes: Partial): Promise { + try { + const patch: Record = {} + if (changes.qty != null) patch.qty = changes.qty + if (changes.price != null) patch.limit_price = changes.price + if (changes.stopPrice != null) patch.stop_price = changes.stopPrice + if (changes.trailingAmount != null) patch.trail = changes.trailingAmount + if (changes.trailingPercent != null) patch.trail = changes.trailingPercent + if (changes.timeInForce) patch.time_in_force = changes.timeInForce + + const result = await this.client.replaceOrder(orderId, patch) 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 cancelOrder(orderId: string): Promise { + try { + await this.client.cancelOrder(orderId) + return true + } catch { + return false + } + } + + async closePosition(contract: Contract, qty?: number): Promise { + const symbol = resolveSymbol(contract, this.provider) + if (!symbol) { + return { success: false, error: 'Cannot resolve contract to Alpaca symbol' } + } + + // Partial close → reverse market order + if (qty != null) { + const positions = await this.getPositions() + const pos = positions.find(p => p.contract.symbol === symbol) + if (!pos) return { success: false, error: `No position for ${symbol}` } + + return this.placeOrder({ + contract, + side: pos.side === 'long' ? 'sell' : 'buy', + type: 'market', + qty, + timeInForce: 'day', + }) + } + + // Full close → native Alpaca API + 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) } + } + } + + // ---- Queries ---- + + async getAccount(): Promise { + const [account, positions, realizedPnL] = await Promise.all([ + this.client.getAccount() as Promise, + this.client.getPositions() as Promise, + this.getRealizedPnL(), + ]) + + // Alpaca account API doesn't provide unrealizedPnL — aggregate from positions + const unrealizedPnL = positions.reduce((sum, p) => sum + parseFloat(p.unrealized_pl), 0) + + return { + cash: parseFloat(account.cash), + equity: parseFloat(account.equity), + unrealizedPnL, + realizedPnL, + portfolioValue: parseFloat(account.portfolio_value), + buyingPower: parseFloat(account.buying_power), + dayTradeCount: account.daytrade_count, + dayTradingBuyingPower: parseFloat(account.daytrading_buying_power), + } + } + + async getPositions(): Promise { + const raw = await this.client.getPositions() as AlpacaPositionRaw[] + + return raw.map(p => ({ + contract: makeContract(p.symbol, this.provider), + 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: Math.abs(parseFloat(p.market_value)), + unrealizedPnL: parseFloat(p.unrealized_pl), + unrealizedPnLPercent: parseFloat(p.unrealized_plpc) * 100, + costBasis: parseFloat(p.cost_basis), + leverage: 1, + })) + } + + 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 getQuote(contract: Contract): Promise { + const symbol = resolveSymbol(contract, this.provider) + if (!symbol) throw new Error('Cannot resolve contract to Alpaca symbol') + + const snapshot = await this.client.getSnapshot(symbol) as AlpacaSnapshotRaw + + return { + contract: makeContract(symbol, this.provider), + last: snapshot.LatestTrade.Price, + bid: snapshot.LatestQuote.BidPrice, + ask: snapshot.LatestQuote.AskPrice, + volume: snapshot.DailyBar.Volume, + timestamp: new Date(snapshot.LatestTrade.Timestamp), + } + } + + // ---- Capabilities ---- + + getCapabilities(): AccountCapabilities { + return { + supportedSecTypes: ['STK'], + supportedOrderTypes: ['market', 'limit', 'stop', 'stop_limit', 'trailing_stop'], + } + } + + 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), + } + } + + // ---- Realized PnL ---- + + /** + * Get realized PnL from Alpaca FILL activities with TTL cache. + * Fetches all historical fills, matches buys against sells per symbol using FIFO, + * and sums the realized profit/loss. + */ + private async getRealizedPnL(): Promise { + const now = Date.now() + if (this.realizedPnLCache && (now - this.realizedPnLCache.updatedAt) < AlpacaAccount.REALIZED_PNL_TTL_MS) { + return this.realizedPnLCache.value + } + + try { + const fills = await this.fetchAllFills() + const value = computeRealizedPnL(fills) + this.realizedPnLCache = { value, updatedAt: now } + return value + } catch (err) { + // On error, return cached value if available, otherwise 0 + console.warn(`AlpacaAccount[${this.id}]: failed to fetch FILL activities:`, err) + return this.realizedPnLCache?.value ?? 0 + } + } + + /** Paginate through all FILL activities (newest first by default). */ + private async fetchAllFills(): Promise { + const all: AlpacaFillActivityRaw[] = [] + let pageToken: string | undefined + + for (;;) { + const page = await this.client.getAccountActivities({ + activityTypes: 'FILL', + pageSize: 100, + pageToken, + direction: 'asc', // oldest first → natural FIFO order + until: undefined, + after: undefined, + date: undefined, + }) as AlpacaFillActivityRaw[] + + if (!page || page.length === 0) break + all.push(...page) + + // Alpaca pagination: last item's id is the next page_token + if (page.length < 100) break + pageToken = (page[page.length - 1] as unknown as { id: string }).id + } + + return all + } + + // ---- Internal ---- + + private mapOrder(o: AlpacaOrderRaw): Order { + return { + id: o.id, + contract: makeContract(o.symbol, this.provider), + side: o.side as 'buy' | 'sell', + type: o.type as Order['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 Order['timeInForce'], + extendedHours: o.extended_hours, + status: mapAlpacaOrderStatus(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, + } + } +} diff --git a/src/extension/trading/providers/alpaca/AlpacaPlatform.ts b/src/extension/trading/providers/alpaca/AlpacaPlatform.ts new file mode 100644 index 00000000..002de861 --- /dev/null +++ b/src/extension/trading/providers/alpaca/AlpacaPlatform.ts @@ -0,0 +1,32 @@ +import type { IPlatform, PlatformCredentials } from '../../platform.js' +import { AlpacaAccount } from './AlpacaAccount.js' + +export interface AlpacaPlatformConfig { + id: string + label?: string + paper: boolean +} + +export class AlpacaPlatform implements IPlatform { + readonly id: string + readonly label: string + readonly providerType = 'alpaca' + + private readonly config: AlpacaPlatformConfig + + constructor(config: AlpacaPlatformConfig) { + this.config = config + this.id = config.id + this.label = config.label ?? (config.paper ? 'Alpaca Paper' : 'Alpaca Live') + } + + createAccount(credentials: PlatformCredentials): AlpacaAccount { + return new AlpacaAccount({ + id: credentials.id, + label: credentials.label, + apiKey: credentials.apiKey ?? '', + secretKey: credentials.apiSecret ?? '', + paper: this.config.paper, + }) + } +} diff --git a/src/extension/trading/providers/alpaca/alpaca-contracts.ts b/src/extension/trading/providers/alpaca/alpaca-contracts.ts new file mode 100644 index 00000000..3caa9a94 --- /dev/null +++ b/src/extension/trading/providers/alpaca/alpaca-contracts.ts @@ -0,0 +1,66 @@ +/** + * Contract resolution helpers for Alpaca. + * + * Pure functions parameterized by provider string. + */ + +import type { Contract } from '../../contract.js' +import type { Order } from '../../interfaces.js' + +/** Build a fully qualified Contract for an Alpaca ticker. */ +export function makeContract(ticker: string, provider: string): Contract { + return { + aliceId: `${provider}-${ticker}`, + symbol: ticker, + secType: 'STK', + exchange: 'SMART', + currency: 'USD', + } +} + +/** Extract native symbol from aliceId, or null if not ours. */ +export function parseAliceId(aliceId: string, provider: string): string | null { + const prefix = `${provider}-` + if (!aliceId.startsWith(prefix)) return null + return aliceId.slice(prefix.length) +} + +/** + * Resolve a Contract to an Alpaca ticker symbol. + * Accepts: aliceId, or symbol (+ optional secType check). + */ +export function resolveSymbol(contract: Contract, provider: string): string | null { + if (contract.aliceId) { + return parseAliceId(contract.aliceId, provider) + } + if (contract.symbol) { + // If secType is specified and not STK, not our domain + if (contract.secType && contract.secType !== 'STK') return null + return contract.symbol.toUpperCase() + } + return null +} + +export function mapAlpacaOrderStatus(alpacaStatus: string): Order['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/trading/providers/alpaca/alpaca-pnl.ts b/src/extension/trading/providers/alpaca/alpaca-pnl.ts new file mode 100644 index 00000000..09236c66 --- /dev/null +++ b/src/extension/trading/providers/alpaca/alpaca-pnl.ts @@ -0,0 +1,60 @@ +/** + * Realized PnL calculation via FIFO lot matching. + */ + +import type { AlpacaFillActivityRaw } from './alpaca-types.js' + +/** + * FIFO lot matching: track buy lots per symbol, realize PnL on sells. + * Handles both long-only and short-selling (sell before buy → short lots). + */ +export function computeRealizedPnL(fills: AlpacaFillActivityRaw[]): number { + // Per-symbol FIFO queue: { qty, price }[] + // Positive qty = long lot, negative qty = short lot + const lots = new Map>() + let totalRealized = 0 + + for (const fill of fills) { + const symbol = fill.symbol + const price = parseFloat(fill.price) + const qty = parseFloat(fill.qty) + const isBuy = fill.side === 'buy' + + if (!lots.has(symbol)) lots.set(symbol, []) + const queue = lots.get(symbol)! + + // Determine if this fill opens or closes + // Opening: buy when no short lots (or queue empty), sell when no long lots + // Closing: buy against short lots, sell against long lots + let remaining = qty + + while (remaining > 0 && queue.length > 0) { + const front = queue[0] + const isClosing = isBuy ? front.qty < 0 : front.qty > 0 + + if (!isClosing) break // Same direction → this fill opens new lots + + const matchQty = Math.min(remaining, Math.abs(front.qty)) + + if (front.qty > 0) { + // Closing long: sell at `price`, entry was `front.price` + totalRealized += matchQty * (price - front.price) + } else { + // Closing short: buy at `price`, entry was `front.price` + totalRealized += matchQty * (front.price - price) + } + + remaining -= matchQty + front.qty += isBuy ? matchQty : -matchQty // shrink lot toward 0 + + if (Math.abs(front.qty) < 1e-10) queue.shift() // lot fully consumed + } + + // Remaining qty opens new lots + if (remaining > 0) { + queue.push({ qty: isBuy ? remaining : -remaining, price }) + } + } + + return Math.round(totalRealized * 100) / 100 // round to cents +} diff --git a/src/extension/trading/providers/alpaca/alpaca-types.ts b/src/extension/trading/providers/alpaca/alpaca-types.ts new file mode 100644 index 00000000..2a5c7fde --- /dev/null +++ b/src/extension/trading/providers/alpaca/alpaca-types.ts @@ -0,0 +1,75 @@ +export interface AlpacaAccountConfig { + id?: string + label?: string + apiKey: string + secretKey: string + paper: boolean +} + +// ==================== Alpaca SDK raw shapes ==================== + +export interface AlpacaAccountRaw { + cash: string + portfolio_value: string + equity: string + buying_power: string + daytrade_count: number + daytrading_buying_power: string +} + +export 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 +} + +export 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 +} + +export interface AlpacaSnapshotRaw { + LatestTrade: { Price: number; Timestamp: string } + LatestQuote: { BidPrice: number; AskPrice: number; Timestamp: string } + DailyBar: { Volume: number } +} + +export interface AlpacaFillActivityRaw { + activity_type: 'FILL' + symbol: string + side: string + qty: string + price: string + cum_qty: string + leaves_qty: string + transaction_time: string + order_id: string + type: string // 'fill' | 'partial_fill' +} + +export interface AlpacaClockRaw { + is_open: boolean + next_open: string + next_close: string + timestamp: string +} diff --git a/src/extension/trading/providers/alpaca/index.ts b/src/extension/trading/providers/alpaca/index.ts new file mode 100644 index 00000000..6e8c7557 --- /dev/null +++ b/src/extension/trading/providers/alpaca/index.ts @@ -0,0 +1,3 @@ +export { AlpacaAccount } from './AlpacaAccount.js' +export { computeRealizedPnL } from './alpaca-pnl.js' +export type { AlpacaAccountConfig } from './alpaca-types.js' diff --git a/src/extension/trading/providers/ccxt/CcxtAccount.ts b/src/extension/trading/providers/ccxt/CcxtAccount.ts new file mode 100644 index 00000000..6d2d48e2 --- /dev/null +++ b/src/extension/trading/providers/ccxt/CcxtAccount.ts @@ -0,0 +1,558 @@ +/** + * CcxtAccount — ITradingAccount adapter for CCXT exchanges + * + * Direct implementation against ccxt unified API. No SymbolMapper — + * contract resolution searches exchange.markets on demand. + * aliceId format: "{exchange}-{market.id}" (e.g. "bybit-BTCUSDT"). + */ + +import ccxt from 'ccxt' +import type { Exchange, Order as CcxtOrder } from 'ccxt' +import type { Contract, ContractDescription, ContractDetails, SecType } from '../../contract.js' +import type { + ITradingAccount, + AccountCapabilities, + AccountInfo, + Position, + Order, + OrderRequest, + OrderResult, + Quote, + MarketClock, + FundingRate, + OrderBook, + OrderBookLevel, +} from '../../interfaces.js' +import type { CcxtAccountConfig, CcxtMarket } from './ccxt-types.js' +import { MAX_INIT_RETRIES, INIT_RETRY_BASE_MS } from './ccxt-types.js' +import { + mapOrderStatus, + marketToContract, + contractToCcxt, +} from './ccxt-contracts.js' + +export class CcxtAccount implements ITradingAccount { + readonly id: string + readonly provider: string // "ccxt" or the specific exchange name + readonly label: string + + private exchange: Exchange + private exchangeName: string + private defaultMarketType: 'spot' | 'swap' + private initialized = false + private readonly readOnly: boolean + + // orderId → ccxtSymbol cache (CCXT needs symbol to cancel) + private orderSymbolCache = new Map() + + constructor(config: CcxtAccountConfig) { + this.exchangeName = config.exchange + this.provider = config.exchange // use exchange name as provider (e.g. "bybit", "binance") + this.id = config.id ?? `${config.exchange}-main` + this.label = config.label ?? `${config.exchange.charAt(0).toUpperCase() + config.exchange.slice(1)} ${config.sandbox ? 'Testnet' : 'Live'}` + this.defaultMarketType = config.defaultMarketType + this.readOnly = !config.apiKey || !config.apiSecret + + const exchanges = ccxt as unknown as Record) => Exchange> + const ExchangeClass = exchanges[config.exchange] + if (!ExchangeClass) { + throw new Error(`Unknown CCXT exchange: ${config.exchange}`) + } + + // Default: skip option markets to reduce concurrent requests during loadMarkets + // (bybit fires 6 parallel requests by default, which is unreliable through proxies) + const defaultOptions: Record = { + fetchMarkets: { types: ['spot', 'linear', 'inverse'] }, + } + const mergedOptions = { ...defaultOptions, ...config.options } + + this.exchange = new ExchangeClass({ + apiKey: config.apiKey, + secret: config.apiSecret, + password: config.password, + options: mergedOptions, + }) + + if (config.sandbox) { + this.exchange.setSandboxMode(true) + } + + if (config.demoTrading) { + (this.exchange as unknown as { enableDemoTrading: (enable: boolean) => void }).enableDemoTrading(true) + } + } + + // ---- Helpers ---- + + private get markets() { + return this.exchange.markets as unknown as Record + } + + private ensureInit(): void { + if (!this.initialized) { + throw new Error(`CcxtAccount[${this.id}] not initialized. Call init() first.`) + } + } + + private ensureWritable(): void { + if (this.readOnly) { + throw new Error( + `CcxtAccount[${this.id}] is in read-only mode (no API keys). This operation requires authentication.`, + ) + } + } + + // ---- Lifecycle ---- + + async init(): Promise { + // CCXT's fetchMarkets fires all market-type requests via Promise.all — + // a single failure kills the entire batch. Monkey-patch fetchMarkets to + // run each type sequentially with per-type retries. + const origFetchMarkets = this.exchange.fetchMarkets.bind(this.exchange) + const accountId = this.id + + this.exchange.fetchMarkets = async (params?: Record) => { + const ex = this.exchange as unknown as Record + const opts = (ex['options'] ?? {}) as Record + const fmOpts = (opts['fetchMarkets'] ?? {}) as Record + const types = (fmOpts['types'] ?? ['spot', 'linear', 'inverse']) as string[] + + const allMarkets: unknown[] = [] + for (const type of types) { + for (let attempt = 1; attempt <= MAX_INIT_RETRIES; attempt++) { + try { + // Temporarily override types to load a single type + const prevTypes = fmOpts['types'] + fmOpts['types'] = [type] + const markets = await origFetchMarkets(params) + fmOpts['types'] = prevTypes + allMarkets.push(...markets) + break + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + if (attempt < MAX_INIT_RETRIES) { + const delay = INIT_RETRY_BASE_MS * Math.pow(2, attempt - 1) + console.warn(`CcxtAccount[${accountId}]: fetchMarkets(${type}) attempt ${attempt}/${MAX_INIT_RETRIES} failed, retrying in ${delay}ms...`) + await new Promise(r => setTimeout(r, delay)) + } else { + console.warn(`CcxtAccount[${accountId}]: fetchMarkets(${type}) failed after ${MAX_INIT_RETRIES} attempts: ${msg} — skipping`) + } + } + } + } + return allMarkets as Awaited> + } + + // Now loadMarkets will use our sequential fetchMarkets + await this.exchange.loadMarkets() + + const marketCount = Object.keys(this.exchange.markets).length + if (marketCount === 0) { + throw new Error(`CcxtAccount[${this.id}]: failed to load any markets`) + } + this.initialized = true + const mode = this.readOnly ? ', read-only (no API keys)' : '' + console.log(`CcxtAccount[${this.id}]: connected (${this.exchangeName}, ${marketCount} markets loaded${mode})`) + } + + async close(): Promise { + // CCXT exchanges typically don't need explicit closing + } + + // ---- Contract search ---- + + async searchContracts(pattern: string): Promise { + this.ensureInit() + if (!pattern) return [] + + const searchBase = pattern.toUpperCase() + const matchedMarkets: CcxtMarket[] = [] + + for (const market of Object.values(this.markets)) { + if (market.active === false) continue + if (market.base.toUpperCase() !== searchBase) continue + + // Default filter: only USDT/USD/USDC quoted markets (skip exotic pairs) + const quote = market.quote.toUpperCase() + if (quote !== 'USDT' && quote !== 'USD' && quote !== 'USDC') continue + + matchedMarkets.push(market) + } + + // Sort: preferred market type first, then USDT > USD > USDC + const typeOrder = this.defaultMarketType === 'swap' + ? { swap: 0, future: 1, spot: 2, option: 3 } + : { spot: 0, swap: 1, future: 2, option: 3 } + const quoteOrder: Record = { USDT: 0, USD: 1, USDC: 2 } + + matchedMarkets.sort((a, b) => { + const aType = typeOrder[a.type as keyof typeof typeOrder] ?? 99 + const bType = typeOrder[b.type as keyof typeof typeOrder] ?? 99 + if (aType !== bType) return aType - bType + const aQuote = quoteOrder[a.quote.toUpperCase()] ?? 99 + const bQuote = quoteOrder[b.quote.toUpperCase()] ?? 99 + return aQuote - bQuote + }) + + // Collect derivative types available for this base asset + const derivativeTypes = new Set() + for (const m of matchedMarkets) { + if (m.type === 'future') derivativeTypes.add('FUT') + if (m.type === 'option') derivativeTypes.add('OPT') + } + const derivativeSecTypes: SecType[] | undefined = derivativeTypes.size > 0 + ? Array.from(derivativeTypes) + : undefined + + return matchedMarkets.map(market => ({ + contract: marketToContract(market, this.exchangeName), + derivativeSecTypes, + })) + } + + async getContractDetails(query: Partial): Promise { + this.ensureInit() + + const ccxtSymbol = contractToCcxt(query as Contract, this.markets, this.exchangeName) + if (!ccxtSymbol) return null + + const market = this.markets[ccxtSymbol] + if (!market) return null + + return { + contract: marketToContract(market, this.exchangeName), + longName: `${market.base}/${market.quote} ${market.type}${market.settle ? ` (${market.settle} settled)` : ''}`, + minTick: market.precision?.price, + } + } + + // ---- Trading operations ---- + + async placeOrder(order: OrderRequest): Promise { + this.ensureInit() + this.ensureWritable() + + const ccxtSymbol = contractToCcxt(order.contract, this.markets, this.exchangeName) + if (!ccxtSymbol) { + return { success: false, error: 'Cannot resolve contract to CCXT symbol' } + } + + let size = order.qty + + // Notional → size conversion + if (!size && order.notional) { + const ticker = await this.exchange.fetchTicker(ccxtSymbol) + const price = order.price ?? ticker.last + if (!price) { + return { success: false, error: 'Cannot determine price for notional conversion' } + } + size = order.notional / price + } + + if (!size) { + return { success: false, error: 'Either qty or notional must be provided' } + } + + try { + 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 + if (ccxtOrder.id) { + this.orderSymbolCache.set(ccxtOrder.id, ccxtSymbol) + } + + const status = mapOrderStatus(ccxtOrder.status) + + return { + success: true, + orderId: ccxtOrder.id, + message: `Order ${ccxtOrder.id} ${status}`, + filledPrice: status === 'filled' ? (ccxtOrder.average ?? ccxtOrder.price ?? undefined) : undefined, + filledQty: status === 'filled' ? (ccxtOrder.filled ?? undefined) : undefined, + } + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : String(err) } + } + } + + async cancelOrder(orderId: string): Promise { + this.ensureInit() + this.ensureWritable() + + try { + const ccxtSymbol = this.orderSymbolCache.get(orderId) + await this.exchange.cancelOrder(orderId, ccxtSymbol) + return true + } catch { + return false + } + } + + async modifyOrder(orderId: string, changes: Partial): Promise { + this.ensureInit() + this.ensureWritable() + + try { + const ccxtSymbol = this.orderSymbolCache.get(orderId) + if (!ccxtSymbol) { + return { success: false, error: `Unknown order ${orderId} — cannot resolve symbol for edit` } + } + + // editOrder requires type and side — fetch the original order to fill in defaults + const original = await this.exchange.fetchOrder(orderId, ccxtSymbol) + const result = await this.exchange.editOrder( + orderId, + ccxtSymbol, + (changes.type as string) ?? original.type, + original.side, + changes.qty ?? original.amount, + changes.price ?? original.price, + ) + + return { + success: true, + orderId: result.id, + filledPrice: result.average ?? undefined, + filledQty: result.filled ?? undefined, + } + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : String(err) } + } + } + + async closePosition(contract: Contract, qty?: number): Promise { + this.ensureInit() + this.ensureWritable() + + const positions = await this.getPositions() + const symbol = contract.symbol?.toUpperCase() + const aliceId = contract.aliceId + + const pos = positions.find(p => + (aliceId && p.contract.aliceId === aliceId) || + (symbol && p.contract.symbol === symbol), + ) + + if (!pos) { + return { success: false, error: `No open position for ${aliceId ?? symbol ?? 'unknown'}` } + } + + return this.placeOrder({ + contract: pos.contract, + side: pos.side === 'long' ? 'sell' : 'buy', + type: 'market', + qty: qty ?? pos.qty, + reduceOnly: true, + }) + } + + // ---- Queries ---- + + async getAccount(): Promise { + this.ensureInit() + this.ensureWritable() + + const [balance, rawPositions] = await Promise.all([ + this.exchange.fetchBalance(), + this.exchange.fetchPositions(), + ]) + + 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)) + + let unrealizedPnL = 0 + let realizedPnL = 0 + for (const p of rawPositions) { + unrealizedPnL += parseFloat(String(p.unrealizedPnl ?? 0)) + realizedPnL += parseFloat(String((p as unknown as Record).realizedPnl ?? 0)) + } + + return { + cash: free, + equity: total, + unrealizedPnL, + realizedPnL, + totalMargin: used, + } + } + + async getPositions(): Promise { + this.ensureInit() + this.ensureWritable() + + const raw = await this.exchange.fetchPositions() + const result: Position[] = [] + + for (const p of raw) { + const market = this.markets[p.symbol] + if (!market) continue + + const size = Math.abs(parseFloat(String(p.contracts ?? 0)) * parseFloat(String(p.contractSize ?? 1))) + if (size === 0) continue + + const markPrice = parseFloat(String(p.markPrice ?? 0)) + const entryPrice = parseFloat(String(p.entryPrice ?? 0)) + const marketValue = size * markPrice + const costBasis = size * entryPrice + const unrealizedPnL = parseFloat(String(p.unrealizedPnl ?? 0)) + + result.push({ + contract: marketToContract(market, this.exchangeName), + side: p.side === 'long' ? 'long' : 'short', + qty: size, + avgEntryPrice: entryPrice, + currentPrice: markPrice, + marketValue, + unrealizedPnL, + unrealizedPnLPercent: costBasis > 0 ? (unrealizedPnL / costBasis) * 100 : 0, + costBasis, + leverage: parseFloat(String(p.leverage ?? 1)), + margin: parseFloat(String(p.initialMargin ?? p.collateral ?? 0)), + liquidationPrice: parseFloat(String(p.liquidationPrice ?? 0)) || undefined, + }) + } + + return result + } + + async getOrders(): Promise { + this.ensureInit() + this.ensureWritable() + + 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: Order[] = [] + + for (const o of allOrders) { + const market = this.markets[o.symbol] + if (!market) continue + + if (o.id) { + this.orderSymbolCache.set(o.id, o.symbol) + } + + result.push({ + id: o.id, + contract: marketToContract(market, this.exchangeName), + side: o.side as 'buy' | 'sell', + type: (o.type ?? 'market') as Order['type'], + qty: o.amount ?? 0, + price: o.price ?? undefined, + reduceOnly: o.reduceOnly ?? false, + status: mapOrderStatus(o.status), + filledPrice: o.average ?? undefined, + filledQty: o.filled ?? undefined, + filledAt: o.lastTradeTimestamp ? new Date(o.lastTradeTimestamp) : undefined, + createdAt: new Date(o.timestamp ?? Date.now()), + }) + } + + return result + } + + async getQuote(contract: Contract): Promise { + this.ensureInit() + + const ccxtSymbol = contractToCcxt(contract, this.markets, this.exchangeName) + if (!ccxtSymbol) throw new Error('Cannot resolve contract to CCXT symbol') + + const ticker = await this.exchange.fetchTicker(ccxtSymbol) + const market = this.markets[ccxtSymbol] + + return { + contract: market + ? marketToContract(market, this.exchangeName) + : contract, + last: ticker.last ?? 0, + bid: ticker.bid ?? 0, + ask: ticker.ask ?? 0, + volume: ticker.baseVolume ?? 0, + high: ticker.high ?? undefined, + low: ticker.low ?? undefined, + timestamp: new Date(ticker.timestamp ?? Date.now()), + } + } + + // ---- Capabilities ---- + + getCapabilities(): AccountCapabilities { + return { + supportedSecTypes: ['CRYPTO'], + supportedOrderTypes: ['market', 'limit'], + } + } + + async getMarketClock(): Promise { + return { + isOpen: true, + timestamp: new Date(), + } + } + + // ---- Provider-specific methods ---- + + async getFundingRate(contract: Contract): Promise { + this.ensureInit() + + const ccxtSymbol = contractToCcxt(contract, this.markets, this.exchangeName) + if (!ccxtSymbol) throw new Error('Cannot resolve contract to CCXT symbol') + + const funding = await this.exchange.fetchFundingRate(ccxtSymbol) + const market = this.markets[ccxtSymbol] + + return { + contract: market + ? marketToContract(market, this.exchangeName) + : contract, + 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(contract: Contract, limit?: number): Promise { + this.ensureInit() + + const ccxtSymbol = contractToCcxt(contract, this.markets, this.exchangeName) + if (!ccxtSymbol) throw new Error('Cannot resolve contract to CCXT symbol') + + const book = await this.exchange.fetchOrderBook(ccxtSymbol, limit) + const market = this.markets[ccxtSymbol] + + return { + contract: market + ? marketToContract(market, this.exchangeName) + : contract, + bids: book.bids.map(([p, a]) => [p ?? 0, a ?? 0] as OrderBookLevel), + asks: book.asks.map(([p, a]) => [p ?? 0, a ?? 0] as OrderBookLevel), + timestamp: new Date(book.timestamp ?? Date.now()), + } + } +} diff --git a/src/extension/trading/providers/ccxt/CcxtPlatform.ts b/src/extension/trading/providers/ccxt/CcxtPlatform.ts new file mode 100644 index 00000000..499e449b --- /dev/null +++ b/src/extension/trading/providers/ccxt/CcxtPlatform.ts @@ -0,0 +1,43 @@ +import type { IPlatform, PlatformCredentials } from '../../platform.js' +import { CcxtAccount } from './CcxtAccount.js' + +export interface CcxtPlatformConfig { + id: string + label?: string + exchange: string + sandbox: boolean + demoTrading?: boolean + defaultMarketType: 'spot' | 'swap' + options?: Record +} + +export class CcxtPlatform implements IPlatform { + readonly id: string + readonly label: string + readonly providerType: string + + private readonly config: CcxtPlatformConfig + + constructor(config: CcxtPlatformConfig) { + this.config = config + this.id = config.id + this.providerType = config.exchange + const exchangeLabel = config.exchange.charAt(0).toUpperCase() + config.exchange.slice(1) + this.label = config.label ?? `${exchangeLabel} ${config.defaultMarketType} (${config.sandbox ? 'testnet' : 'live'})` + } + + createAccount(credentials: PlatformCredentials): CcxtAccount { + return new CcxtAccount({ + id: credentials.id, + label: credentials.label, + exchange: this.config.exchange, + apiKey: credentials.apiKey ?? '', + apiSecret: credentials.apiSecret ?? '', + password: credentials.password, + sandbox: this.config.sandbox, + demoTrading: this.config.demoTrading, + defaultMarketType: this.config.defaultMarketType, + options: this.config.options, + }) + } +} diff --git a/src/extension/trading/providers/ccxt/ccxt-contracts.ts b/src/extension/trading/providers/ccxt/ccxt-contracts.ts new file mode 100644 index 00000000..4cfc61fb --- /dev/null +++ b/src/extension/trading/providers/ccxt/ccxt-contracts.ts @@ -0,0 +1,135 @@ +/** + * Contract resolution helpers for CCXT exchanges. + * + * Pure functions parameterized by (markets, exchangeName) — + * no dependency on the CcxtAccount instance. + */ + +import type { Contract, SecType } from '../../contract.js' +import type { Order } from '../../interfaces.js' +import type { CcxtMarket } from './ccxt-types.js' + +// ---- Type mapping ---- + +export function ccxtTypeToSecType(type: string): SecType { + switch (type) { + case 'spot': return 'CRYPTO' + case 'swap': return 'CRYPTO' // perpetual swap is still crypto + case 'future': return 'FUT' + case 'option': return 'OPT' + default: return 'CRYPTO' + } +} + +export function mapOrderStatus(status: string | undefined): Order['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' + } +} + +// ---- Contract ↔ CCXT symbol conversion ---- + +/** + * Convert a CcxtMarket to a Contract. + * aliceId = "{exchangeName}-{market.id}" + */ +export function marketToContract(market: CcxtMarket, exchangeName: string): Contract { + return { + aliceId: `${exchangeName}-${market.id}`, + symbol: market.base, + secType: ccxtTypeToSecType(market.type), + exchange: exchangeName, + currency: market.quote, + localSymbol: market.symbol, // CCXT unified symbol, e.g. "BTC/USDT:USDT" + description: `${market.base}/${market.quote} ${market.type}${market.settle ? ` (${market.settle} settled)` : ''}`, + } +} + +/** Parse aliceId → raw nativeId (market.id) part. */ +export function aliceIdToCcxt(aliceId: string, exchangeName: string): string | null { + const prefix = `${exchangeName}-` + if (!aliceId.startsWith(prefix)) return null + return aliceId.slice(prefix.length) +} + +/** + * Resolve a Contract to a CCXT symbol for API calls. + * Tries: aliceId → localSymbol → symbol as CCXT key → search by base+secType. + */ +export function contractToCcxt( + contract: Contract, + markets: Record, + exchangeName: string, +): string | null { + // 1. aliceId → market.id → look up in markets + if (contract.aliceId) { + const ccxtSymbol = aliceIdToCcxt(contract.aliceId, exchangeName) + if (ccxtSymbol && markets[ccxtSymbol]) return ccxtSymbol + // aliceId uses market.id, but markets are indexed by ccxt symbol + // search by market.id + for (const m of Object.values(markets)) { + if (`${exchangeName}-${m.id}` === contract.aliceId) return m.symbol + } + return null + } + + // 2. localSymbol is the CCXT unified symbol + if (contract.localSymbol && markets[contract.localSymbol]) { + return contract.localSymbol + } + + // 3. symbol might be a CCXT unified symbol (e.g. "BTC/USDT:USDT") + if (contract.symbol && markets[contract.symbol]) { + return contract.symbol + } + + // 4. Search by base symbol + secType (resolve to unique) + if (contract.symbol) { + const candidates = resolveContractSync(contract, markets) + if (candidates.length === 1) return candidates[0] + if (candidates.length > 1) { + // Ambiguous — caller should have resolved first + return null + } + } + + return null +} + +/** Synchronous search returning CCXT symbols. Used by contractToCcxt. */ +export function resolveContractSync( + query: Partial, + markets: Record, +): string[] { + if (!query.symbol) return [] + + const searchBase = query.symbol.toUpperCase() + const results: string[] = [] + + for (const market of Object.values(markets)) { + if (market.active === false) continue + if (market.base.toUpperCase() !== searchBase) continue + + if (query.secType) { + const marketSecType = ccxtTypeToSecType(market.type) + if (marketSecType !== query.secType) continue + } + + if (query.currency && market.quote.toUpperCase() !== query.currency.toUpperCase()) continue + + if (!query.currency) { + const quote = market.quote.toUpperCase() + if (quote !== 'USDT' && quote !== 'USD' && quote !== 'USDC') continue + } + + results.push(market.symbol) + } + + return results +} diff --git a/src/extension/trading/providers/ccxt/ccxt-tools.ts b/src/extension/trading/providers/ccxt/ccxt-tools.ts new file mode 100644 index 00000000..d4d9fb23 --- /dev/null +++ b/src/extension/trading/providers/ccxt/ccxt-tools.ts @@ -0,0 +1,81 @@ +/** + * AI tool factories for CCXT exchanges. + * + * Registered dynamically when a CCXT account comes online. + */ + +import { tool } from 'ai' +import { z } from 'zod' +import { resolveAccounts } from '../../adapter.js' +import type { AccountResolver } from '../../adapter.js' +import { CcxtAccount } from './CcxtAccount.js' + +export function createCcxtProviderTools(resolver: AccountResolver) { + const { accountManager } = resolver + + /** Resolve to exactly one CcxtAccount. Returns error object if unable. */ + const resolveCcxtOne = (source?: string): { account: CcxtAccount; id: string } | { error: string } => { + const targets = resolveAccounts(accountManager, source) + .filter((t): t is { account: CcxtAccount; id: string } => t.account instanceof CcxtAccount) + if (targets.length === 0) return { error: 'No CCXT account available.' } + if (targets.length > 1) { + return { error: `Multiple CCXT accounts: ${targets.map(t => t.id).join(', ')}. Specify source.` } + } + return targets[0] + } + + const sourceDesc = + 'Account source — matches account id or provider name. Auto-resolves if only one CCXT account exists.' + + return { + getFundingRate: 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. +Use searchContracts first to get the aliceId.`, + inputSchema: z.object({ + aliceId: z.string().describe('Contract identifier from searchContracts (e.g. "bybit-BTCUSDT")'), + source: z.string().optional().describe(sourceDesc), + }), + execute: async ({ aliceId, source }) => { + const resolved = resolveCcxtOne(source) + if ('error' in resolved) return resolved + const { account, id } = resolved + const result = await account.getFundingRate({ aliceId }) + return { source: id, ...result } + }, + }), + + getOrderBook: tool({ + description: `Query the order book (market depth) for a contract. + +Returns bids and asks sorted by price. Each level is [price, amount]. +Use this to evaluate liquidity and potential slippage before placing large orders. +Use searchContracts first to get the aliceId.`, + inputSchema: z.object({ + aliceId: z.string().describe('Contract identifier from searchContracts (e.g. "bybit-BTCUSDT")'), + limit: z + .number() + .int() + .min(1) + .max(100) + .optional() + .describe('Number of price levels per side (default: 20)'), + source: z.string().optional().describe(sourceDesc), + }), + execute: async ({ aliceId, limit, source }) => { + const resolved = resolveCcxtOne(source) + if ('error' in resolved) return resolved + const { account, id } = resolved + const result = await account.getOrderBook({ aliceId }, limit ?? 20) + return { source: id, ...result } + }, + }), + + } +} diff --git a/src/extension/trading/providers/ccxt/ccxt-types.ts b/src/extension/trading/providers/ccxt/ccxt-types.ts new file mode 100644 index 00000000..5490a917 --- /dev/null +++ b/src/extension/trading/providers/ccxt/ccxt-types.ts @@ -0,0 +1,26 @@ +export interface CcxtAccountConfig { + id?: string + label?: string + exchange: string + apiKey: string + apiSecret: string + password?: string + sandbox: boolean + demoTrading?: boolean + defaultMarketType: 'spot' | 'swap' + options?: Record +} + +export interface CcxtMarket { + id: string // exchange-native symbol, e.g. "BTCUSDT" + symbol: string // CCXT unified format, e.g. "BTC/USDT:USDT" + base: string // e.g. "BTC" + quote: string // e.g. "USDT" + type: string // "spot" | "swap" | "future" | "option" + settle?: string // e.g. "USDT" (for derivatives) + active?: boolean + precision?: { price?: number; amount?: number } +} + +export const MAX_INIT_RETRIES = 8 +export const INIT_RETRY_BASE_MS = 500 diff --git a/src/extension/trading/providers/ccxt/index.ts b/src/extension/trading/providers/ccxt/index.ts new file mode 100644 index 00000000..fb2e91a7 --- /dev/null +++ b/src/extension/trading/providers/ccxt/index.ts @@ -0,0 +1,3 @@ +export { CcxtAccount } from './CcxtAccount.js' +export { createCcxtProviderTools } from './ccxt-tools.js' +export type { CcxtAccountConfig, CcxtMarket } from './ccxt-types.js' diff --git a/src/extension/trading/wallet-state-bridge.spec.ts b/src/extension/trading/wallet-state-bridge.spec.ts new file mode 100644 index 00000000..730472d6 --- /dev/null +++ b/src/extension/trading/wallet-state-bridge.spec.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { createWalletStateBridge } from './wallet-state-bridge.js' +import { MockTradingAccount, makePosition, makeOrder } from './__test__/mock-account.js' + +describe('createWalletStateBridge', () => { + let account: MockTradingAccount + + beforeEach(() => { + account = new MockTradingAccount() + }) + + it('returns a function', () => { + const bridge = createWalletStateBridge(account) + expect(typeof bridge).toBe('function') + }) + + it('assembles GitState from account data', async () => { + account.setAccountInfo({ cash: 50_000, equity: 55_000, unrealizedPnL: 3_000, realizedPnL: 800 }) + account.setPositions([makePosition()]) + account.setOrders([ + makeOrder({ id: 'o1', status: 'filled' }), + makeOrder({ id: 'o2', status: 'pending' }), + makeOrder({ id: 'o3', status: 'cancelled' }), + ]) + + const bridge = createWalletStateBridge(account) + const state = await bridge() + + expect(state.cash).toBe(50_000) + expect(state.equity).toBe(55_000) + expect(state.unrealizedPnL).toBe(3_000) + expect(state.realizedPnL).toBe(800) + expect(state.positions).toHaveLength(1) + expect(state.pendingOrders).toHaveLength(1) + expect(state.pendingOrders[0].id).toBe('o2') + }) + + it('calls all three account methods in parallel', async () => { + const bridge = createWalletStateBridge(account) + await bridge() + + expect(account.getAccount).toHaveBeenCalledTimes(1) + expect(account.getPositions).toHaveBeenCalledTimes(1) + expect(account.getOrders).toHaveBeenCalledTimes(1) + }) + + it('returns empty pendingOrders when no orders are pending', async () => { + account.setOrders([ + makeOrder({ status: 'filled' }), + makeOrder({ id: 'o2', status: 'cancelled' }), + ]) + + const bridge = createWalletStateBridge(account) + const state = await bridge() + + expect(state.pendingOrders).toHaveLength(0) + }) +}) diff --git a/src/extension/trading/wallet-state-bridge.ts b/src/extension/trading/wallet-state-bridge.ts new file mode 100644 index 00000000..dfea8f0c --- /dev/null +++ b/src/extension/trading/wallet-state-bridge.ts @@ -0,0 +1,28 @@ +/** + * Unified Wallet State Bridge + * + * ITradingAccount → GitState assembly. + * Used as the TradingGitConfig.getGitState callback. + */ + +import type { ITradingAccount } from './interfaces.js' +import type { GitState } from './git/types.js' + +export function createWalletStateBridge(account: ITradingAccount) { + return async (): Promise => { + const [accountInfo, positions, orders] = await Promise.all([ + account.getAccount(), + account.getPositions(), + account.getOrders(), + ]) + + return { + cash: accountInfo.cash, + equity: accountInfo.equity, + unrealizedPnL: accountInfo.unrealizedPnL, + realizedPnL: accountInfo.realizedPnL, + positions, + pendingOrders: orders.filter(o => o.status === 'pending'), + } + } +} diff --git a/src/main.ts b/src/main.ts index d385d8a4..5e56ab97 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,33 +1,24 @@ import { readFile, writeFile, appendFile, mkdir } from 'fs/promises' import { resolve, dirname } from 'path' import { Engine } from './core/engine.js' -import { loadConfig } from './core/config.js' +import { loadConfig, loadTradingConfig } from './core/config.js' import type { Plugin, EngineContext, ReconnectResult } from './core/types.js' import { McpPlugin } from './plugins/mcp.js' import { TelegramPlugin } from './connectors/telegram/index.js' import { WebPlugin } from './connectors/web/index.js' import { McpAskPlugin } from './connectors/mcp-ask/index.js' import { createThinkingTools } from './extension/thinking-kit/index.js' -import type { WalletExportState } from './extension/crypto-trading/index.js' import { - Wallet, - createCryptoTradingEngine, - createCryptoTradingTools, - createCryptoOperationDispatcher, - createCryptoWalletStateBridge, - createGuardPipeline, - resolveGuards, -} from './extension/crypto-trading/index.js' -import type { SecOperation, SecWalletExportState } from './extension/securities-trading/index.js' -import { - SecWallet, - createSecuritiesTradingEngine, - createSecuritiesTradingTools, - createSecOperationDispatcher, - createSecWalletStateBridge, - createSecGuardPipeline, - resolveSecGuards, -} from './extension/securities-trading/index.js' + AccountManager, + CcxtAccount, + createCcxtProviderTools, + wireAccountTrading, + createTradingTools, + createPlatformFromConfig, + createAccountFromConfig, + validatePlatformRefs, +} from './extension/trading/index.js' +import type { AccountSetup, GitExportState, ITradingGit, IPlatform } from './extension/trading/index.js' import { Brain, createBrainTools } from './extension/brain/index.js' import type { BrainExportState } from './extension/brain/index.js' import { createBrowserTools } from './extension/browser/index.js' @@ -38,8 +29,7 @@ import { OpenBBCurrencyClient } from './openbb/currency/index.js' import { OpenBBEconomyClient } from './openbb/economy/index.js' import { OpenBBCommodityClient } from './openbb/commodity/index.js' import { OpenBBNewsClient } from './openbb/news/index.js' -import { createCryptoTools } from './extension/crypto/index.js' -import { createCurrencyTools } from './extension/currency/index.js' +import { createMarketSearchTools } from './extension/market/index.js' import { createNewsTools } from './extension/news/index.js' import { createAnalysisTools } from './extension/analysis-kit/index.js' import { SessionStore } from './core/session.js' @@ -54,9 +44,20 @@ import { createCronEngine, createCronListener, createCronTools } from './task/cr import { createHeartbeat } from './task/heartbeat/index.js' import { NewsCollectorStore, NewsCollector, wrapNewsToolsForPiggyback, createNewsArchiveTools } from './extension/news-collector/index.js' -const WALLET_FILE = resolve('data/crypto-trading/commit.json') -const SEC_WALLET_FILE = resolve('data/securities-trading/commit.json') +// ==================== Persistence paths ==================== + const BRAIN_FILE = resolve('data/brain/commit.json') + +/** Per-account git state path. Falls back to legacy paths for backward compat. + * TODO: remove LEGACY_GIT_PATHS before v1.0 */ +function gitFilePath(accountId: string): string { + return resolve(`data/trading/${accountId}/commit.json`) +} +const LEGACY_GIT_PATHS: Record = { + 'bybit-main': resolve('data/crypto-trading/commit.json'), + 'alpaca-paper': resolve('data/securities-trading/commit.json'), + 'alpaca-live': resolve('data/securities-trading/commit.json'), +} const FRONTAL_LOBE_FILE = resolve('data/brain/frontal-lobe.md') const EMOTION_LOG_FILE = resolve('data/brain/emotion-log.md') const PERSONA_FILE = resolve('data/brain/persona.md') @@ -75,90 +76,101 @@ async function readWithDefault(target: string, defaultFile: string): Promise { + await mkdir(dirname(filePath), { recursive: true }) + await writeFile(filePath, JSON.stringify(state, null, 2)) + } +} - // ==================== Infrastructure ==================== +/** Read saved git state from disk, trying primary path then legacy fallback. */ +async function loadGitState(accountId: string): Promise { + const primary = gitFilePath(accountId) + try { + return JSON.parse(await readFile(primary, 'utf-8')) as GitExportState + } catch { /* try legacy */ } + const legacy = LEGACY_GIT_PATHS[accountId] + if (legacy) { + try { + return JSON.parse(await readFile(legacy, 'utf-8')) as GitExportState + } catch { /* no saved state */ } + } + return undefined +} - // Start CCXT init in background — do NOT await here, letting everything else proceed immediately - const cryptoInitPromise = createCryptoTradingEngine(config).catch((err) => { - console.warn('crypto trading engine init failed (non-fatal, continuing without it):', err) - return null - }) +async function main() { + const config = await loadConfig() - // Run Securities init + all local file reads in parallel - const [ - secResultOrNull, - savedState, - secSavedState, - brainExport, - persona, - ] = await Promise.all([ - createSecuritiesTradingEngine(config).catch((err) => { - console.warn('securities trading engine init failed (non-fatal, continuing without it):', err) - return null - }), - readFile(WALLET_FILE, 'utf-8').then((r) => JSON.parse(r) as WalletExportState).catch(() => undefined), - readFile(SEC_WALLET_FILE, 'utf-8').then((r) => JSON.parse(r) as SecWalletExportState).catch(() => undefined), - readFile(BRAIN_FILE, 'utf-8').then((r) => JSON.parse(r) as BrainExportState).catch(() => undefined), - readWithDefault(PERSONA_FILE, PERSONA_DEFAULT), - ]) + // ==================== Trading Account Manager ==================== - let secResultRef = secResultOrNull + const accountManager = new AccountManager() + // Mutable map: accountId → setup. Needed for reconnect (re-wiring) and git lookups. + const accountSetups = new Map() - // ==================== Commit callbacks ==================== + // ==================== Platform-driven Account Init ==================== - const onCryptoCommit = async (state: WalletExportState) => { - await mkdir(resolve('data/crypto-trading'), { recursive: true }) - await writeFile(WALLET_FILE, JSON.stringify(state, null, 2)) + const tradingConfig = await loadTradingConfig() + const platformRegistry = new Map() + for (const pc of tradingConfig.platforms) { + platformRegistry.set(pc.id, createPlatformFromConfig(pc)) } - - const onSecCommit = async (state: SecWalletExportState) => { - await mkdir(resolve('data/securities-trading'), { recursive: true }) - await writeFile(SEC_WALLET_FILE, JSON.stringify(state, null, 2)) + validatePlatformRefs([...platformRegistry.values()], tradingConfig.accounts) + + /** Initialize and register a single account. Returns true if successful. */ + async function initAccount( + accountCfg: { id: string; platformId: string; guards: Array<{ type: string; options: Record }> }, + platform: IPlatform, + ): Promise { + const account = createAccountFromConfig(platform, accountCfg) + try { + await account.init() + } catch (err) { + console.warn(`trading: ${accountCfg.id} init failed (non-fatal):`, err) + return false + } + const savedState = await loadGitState(accountCfg.id) + const filePath = gitFilePath(accountCfg.id) + const setup = wireAccountTrading(account, { + guards: accountCfg.guards, + savedState, + onCommit: createGitPersister(filePath), + }) + accountManager.addAccount(account, accountCfg.platformId) + accountSetups.set(account.id, setup) + console.log(`trading: ${account.label} initialized`) + return true } - // ==================== Securities Trading ==================== - - const secWalletStateBridge = secResultRef - ? createSecWalletStateBridge(secResultRef.engine) - : undefined + // Alpaca accounts — sync init (fast, blocks startup) + // CCXT accounts — async background init (loadMarkets is slow) + const ccxtAccountConfigs: Array<{ cfg: typeof tradingConfig.accounts[number]; platform: IPlatform }> = [] - const secGuards = resolveSecGuards(config.securities.guards) - - const secWalletConfig = secResultRef - ? { - executeOperation: createSecGuardPipeline( - createSecOperationDispatcher(secResultRef.engine), - secResultRef.engine, - secGuards, - ), - getWalletState: secWalletStateBridge!, - onCommit: onSecCommit, - } - : { - executeOperation: async (_op: SecOperation) => { - throw new Error('Securities trading service not connected') - }, - getWalletState: async () => { - throw new Error('Securities trading service not connected') - }, - onCommit: onSecCommit, - } - - const secWallet = secSavedState - ? SecWallet.restore(secSavedState, secWalletConfig) - : new SecWallet(secWalletConfig) - - // Mutable wallet references — updated on reconnect so REST getters always return current instance - let currentCryptoWallet: InstanceType | null = null - let currentSecWallet: InstanceType = secWallet + for (const accCfg of tradingConfig.accounts) { + const platform = platformRegistry.get(accCfg.platformId)! + if (platform.providerType === 'alpaca') { + await initAccount(accCfg, platform) + } else { + ccxtAccountConfigs.push({ cfg: accCfg, platform }) + } + } - // Kept for shutdown cleanup reference (populated when CCXT resolves) - let cryptoResultRef: Awaited> = null + // CCXT init in background — register tools when ready + const ccxtInitPromise = ccxtAccountConfigs.length > 0 + ? (async () => { + for (const { cfg, platform } of ccxtAccountConfigs) { + await initAccount(cfg, platform) + } + })() + : Promise.resolve() // ==================== Brain ==================== + const [brainExport, persona] = await Promise.all([ + readFile(BRAIN_FILE, 'utf-8').then((r) => JSON.parse(r) as BrainExportState).catch(() => undefined), + readWithDefault(PERSONA_FILE, PERSONA_DEFAULT), + ]) + const brainDir = resolve('data/brain') const brainOnCommit = async (state: BrainExportState) => { await mkdir(brainDir, { recursive: true }) @@ -226,16 +238,22 @@ async function main() { const toolCenter = new ToolCenter() toolCenter.register(createThinkingTools(), 'thinking') - // Crypto trading tools are injected later in the background when CCXT resolves - if (secResultRef) { - toolCenter.register(createSecuritiesTradingTools(secResultRef.engine, secWallet, secWalletStateBridge), 'securities-trading') - } + + // One unified set of trading tools — routes via `source` parameter at runtime + toolCenter.register( + createTradingTools({ + accountManager, + getGit: (id) => accountSetups.get(id)?.git, + getGitState: (id) => accountSetups.get(id)?.getGitState(), + }), + 'trading', + ) + toolCenter.register(createBrainTools(brain), 'brain') toolCenter.register(createBrowserTools(), 'browser') toolCenter.register(createCronTools(cronEngine), 'cron') - toolCenter.register(createEquityTools(symbolIndex, equityClient), 'equity') - toolCenter.register(createCryptoTools(cryptoClient), 'crypto-data') - toolCenter.register(createCurrencyTools(currencyClient), 'currency-data') + toolCenter.register(createMarketSearchTools(symbolIndex, cryptoClient, currencyClient), 'market-search') + toolCenter.register(createEquityTools(equityClient), 'equity') let newsTools = createNewsTools(newsClient, { companyProvider: providers.newsCompany, worldProvider: providers.newsWorld, @@ -249,7 +267,7 @@ async function main() { } toolCenter.register(createAnalysisTools(equityClient, cryptoClient, currencyClient), 'analysis') - console.log(`tool-center: ${toolCenter.list().length} tools registered (crypto trading pending ccxt)`) + console.log(`tool-center: ${toolCenter.list().length} tools registered`) // ==================== AI Provider Chain ==================== @@ -302,86 +320,70 @@ async function main() { console.log(`news-collector: started (${config.newsCollector.feeds.length} feeds, every ${config.newsCollector.intervalMinutes}m)`) } - // ==================== Engine Reconnect ==================== - - let cryptoReconnecting = false - const reconnectCrypto = async (): Promise => { - if (cryptoReconnecting) return { success: false, error: 'Reconnect already in progress' } - cryptoReconnecting = true - try { - const freshConfig = await loadConfig() + // ==================== Account Reconnect ==================== - // Create new engine FIRST — if this fails, old engine stays functional - const newResult = await createCryptoTradingEngine(freshConfig) - await cryptoResultRef?.close() - cryptoResultRef = newResult + const reconnectingAccounts = new Set() - if (!newResult) { - return { success: true, message: 'Crypto trading disabled (provider: none)' } + const reconnectAccount = async (accountId: string): Promise => { + if (reconnectingAccounts.has(accountId)) { + return { success: false, error: 'Reconnect already in progress' } + } + reconnectingAccounts.add(accountId) + try { + // Re-read trading config to pick up credential/guard changes + const freshTrading = await loadTradingConfig() + + // Close old account + const currentAccount = accountManager.getAccount(accountId) + if (currentAccount) { + await currentAccount.close() + accountManager.removeAccount(accountId) + accountSetups.delete(accountId) } - const bridge = createCryptoWalletStateBridge(newResult.engine) - const rawDispatcher = createCryptoOperationDispatcher(newResult.engine) - const guards = resolveGuards(freshConfig.crypto.guards) - const walletConfig = { - executeOperation: createGuardPipeline(rawDispatcher, newResult.engine, guards), - getWalletState: bridge, - onCommit: onCryptoCommit, + // Find this account in fresh config + const accCfg = freshTrading.accounts.find((a) => a.id === accountId) + if (!accCfg) { + return { success: true, message: `Account "${accountId}" not found in config (removed or disabled)` } } - const savedWallet = await readFile(WALLET_FILE, 'utf-8') - .then((r) => JSON.parse(r) as WalletExportState).catch(() => undefined) - const newWallet = savedWallet ? Wallet.restore(savedWallet, walletConfig) : new Wallet(walletConfig) - currentCryptoWallet = newWallet - - toolCenter.register(createCryptoTradingTools(newResult.engine, newWallet, bridge), 'crypto-trading') - console.log(`reconnect: crypto trading engine online (${toolCenter.list().length} tools)`) - return { success: true, message: 'Crypto trading engine reconnected' } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - console.error('reconnect: crypto failed:', msg) - return { success: false, error: msg } - } finally { - cryptoReconnecting = false - } - } - let secReconnecting = false - const reconnectSecurities = async (): Promise => { - if (secReconnecting) return { success: false, error: 'Reconnect already in progress' } - secReconnecting = true - try { - const freshConfig = await loadConfig() + // Build platform registry from fresh config + const freshPlatforms = new Map() + for (const pc of freshTrading.platforms) { + freshPlatforms.set(pc.id, createPlatformFromConfig(pc)) + } - const newResult = await createSecuritiesTradingEngine(freshConfig) - await secResultRef?.close() - secResultRef = newResult + const platform = freshPlatforms.get(accCfg.platformId) + if (!platform) { + return { success: false, error: `Platform "${accCfg.platformId}" not found for account "${accountId}"` } + } - if (!newResult) { - return { success: true, message: 'Securities trading disabled (provider: none)' } + const ok = await initAccount(accCfg, platform) + if (!ok) { + return { success: false, error: `Account "${accountId}" init failed` } } - const bridge = createSecWalletStateBridge(newResult.engine) - const rawDispatcher = createSecOperationDispatcher(newResult.engine) - const guards = resolveSecGuards(freshConfig.securities.guards) - const walletConfig = { - executeOperation: createSecGuardPipeline(rawDispatcher, newResult.engine, guards), - getWalletState: bridge, - onCommit: onSecCommit, + // Re-register CCXT-specific tools if this is a CCXT account + if (platform.providerType !== 'alpaca') { + toolCenter.register( + createCcxtProviderTools({ + accountManager, + getGit: (id) => accountSetups.get(id)?.git, + getGitState: (id) => accountSetups.get(id)?.getGitState(), + }), + 'trading-ccxt', + ) } - const savedWallet = await readFile(SEC_WALLET_FILE, 'utf-8') - .then((r) => JSON.parse(r) as SecWalletExportState).catch(() => undefined) - const newWallet = savedWallet ? SecWallet.restore(savedWallet, walletConfig) : new SecWallet(walletConfig) - currentSecWallet = newWallet - - toolCenter.register(createSecuritiesTradingTools(newResult.engine, newWallet, bridge), 'securities-trading') - console.log(`reconnect: securities trading engine online (${toolCenter.list().length} tools)`) - return { success: true, message: 'Securities trading engine reconnected' } + + const label = accountManager.getAccount(accountId)?.label ?? accountId + console.log(`reconnect: ${label} online`) + return { success: true, message: `${label} reconnected` } } catch (err) { const msg = err instanceof Error ? err.message : String(err) - console.error('reconnect: securities failed:', msg) + console.error(`reconnect: ${accountId} failed:`, msg) return { success: false, error: msg } } finally { - secReconnecting = false + reconnectingAccounts.delete(accountId) } } @@ -468,14 +470,14 @@ async function main() { } } + // ==================== Engine Context ==================== + const ctx: EngineContext = { - config, connectorCenter, engine, cryptoEngine: null, eventLog, heartbeat, cronEngine, - reconnectCrypto, reconnectSecurities, reconnectConnectors, - getCryptoEngine: () => cryptoResultRef?.engine ?? null, - getSecuritiesEngine: () => secResultRef?.engine ?? null, - getCryptoWallet: () => currentCryptoWallet, - getSecWallet: () => currentSecWallet, - toolCenter, + config, connectorCenter, engine, eventLog, heartbeat, cronEngine, toolCenter, + accountManager, + getAccountGit: (id: string): ITradingGit | undefined => accountSetups.get(id)?.git, + reconnectAccount, + reconnectConnectors, } for (const plugin of [...corePlugins, ...optionalPlugins.values()]) { @@ -483,29 +485,27 @@ async function main() { console.log(`plugin started: ${plugin.name}`) } - console.log('engine: started (crypto trading tools pending ccxt init)') + console.log('engine: started') // ==================== CCXT Background Injection ==================== - // When the CCXT engine is ready, register crypto trading tools so the next - // agent call picks them up automatically (VercelAIProvider re-checks tool count). - - cryptoInitPromise.then((cryptoResult) => { - cryptoResultRef = cryptoResult - if (!cryptoResult) return - const bridge = createCryptoWalletStateBridge(cryptoResult.engine) - const rawDispatcher = createCryptoOperationDispatcher(cryptoResult.engine) - const guards = resolveGuards(config.crypto.guards) - const realWalletConfig = { - executeOperation: createGuardPipeline(rawDispatcher, cryptoResult.engine, guards), - getWalletState: bridge, - onCommit: onCryptoCommit, - } - const realWallet = savedState - ? Wallet.restore(savedState, realWalletConfig) - : new Wallet(realWalletConfig) - currentCryptoWallet = realWallet - toolCenter.register(createCryptoTradingTools(cryptoResult.engine, realWallet, bridge), 'crypto-trading') - console.log(`ccxt: crypto trading tools online (${toolCenter.list().length} tools total)`) + // CCXT accounts init in background (loadMarkets is slow). When done, register + // CCXT-specific tools so the next agent call picks them up automatically. + ccxtInitPromise.then(() => { + // Check if any CCXT accounts were successfully registered + const hasCcxt = Array.from(accountSetups.values()).some( + (s) => s.account instanceof CcxtAccount, + ) + if (!hasCcxt) return + + toolCenter.register( + createCcxtProviderTools({ + accountManager, + getGit: (id) => accountSetups.get(id)?.git, + getGitState: (id) => accountSetups.get(id)?.getGitState(), + }), + 'trading-ccxt', + ) + console.log('ccxt: provider tools registered') }) // ==================== Shutdown ==================== @@ -522,8 +522,7 @@ async function main() { } await newsStore.close() await eventLog.close() - await cryptoResultRef?.close() - await secResultRef?.close() + await accountManager.closeAll() process.exit(0) } process.on('SIGINT', shutdown) diff --git a/tsconfig.json b/tsconfig.json index 55624fd3..b7d2c531 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,5 +16,5 @@ } }, "include": ["src"], - "exclude": ["node_modules", "dist", "**/*.spec.ts"] + "exclude": ["node_modules", "dist", "**/*.spec.ts", "src/extension/trading-archived"] } diff --git a/ui/package.json b/ui/package.json index ac897fb9..d6ced5c6 100644 --- a/ui/package.json +++ b/ui/package.json @@ -13,7 +13,8 @@ "marked": "^15.0.12", "marked-highlight": "^2.2.1", "react": "^19.1.0", - "react-dom": "^19.1.0" + "react-dom": "^19.1.0", + "react-router-dom": "^7.13.1" }, "devDependencies": { "@tailwindcss/vite": "^4.1.8", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 38e602e0..028f6a06 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: react-dom: specifier: ^19.1.0 version: 19.2.4(react@19.2.4) + react-router-dom: + specifier: ^7.13.1 + version: 7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) devDependencies: '@tailwindcss/vite': specifier: ^4.1.8 @@ -589,6 +592,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -787,6 +794,23 @@ packages: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} + react-router-dom@7.13.1: + resolution: {integrity: sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.13.1: + resolution: {integrity: sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + react@19.2.4: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} @@ -803,6 +827,9 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1288,6 +1315,8 @@ snapshots: convert-source-map@2.0.0: {} + cookie@1.1.1: {} + csstype@3.2.3: {} debug@4.4.3: @@ -1445,6 +1474,20 @@ snapshots: react-refresh@0.17.0: {} + react-router-dom@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-router: 7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + + react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + cookie: 1.1.1 + react: 19.2.4 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.4(react@19.2.4) + react@19.2.4: {} rollup@4.58.0: @@ -1482,6 +1525,8 @@ snapshots: semver@6.3.1: {} + set-cookie-parser@2.7.2: {} + source-map-js@1.2.1: {} tailwindcss@4.2.0: {} diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 1ff080cd..b513c1fc 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import { Routes, Route, Navigate } from 'react-router-dom' import { Sidebar } from './components/Sidebar' import { ChatPage } from './pages/ChatPage' import { PortfolioPage } from './pages/PortfolioPage' @@ -7,7 +8,6 @@ import { SettingsPage } from './pages/SettingsPage' import { AIProviderPage } from './pages/AIProviderPage' import { DataSourcesPage } from './pages/DataSourcesPage' import { TradingPage } from './pages/TradingPage' -import { SecuritiesPage } from './pages/SecuritiesPage' import { ConnectorsPage } from './pages/ConnectorsPage' import { DevPage } from './pages/DevPage' import { HeartbeatPage } from './pages/HeartbeatPage' @@ -15,12 +15,25 @@ import { ToolsPage } from './pages/ToolsPage' export type Page = | 'chat' | 'portfolio' | 'events' | 'heartbeat' | 'data-sources' | 'connectors' - | 'trading/connection' | 'trading/guards' - | 'securities/connection' | 'securities/guards' + | 'trading' | 'ai-provider' | 'settings' | 'tools' | 'dev' +/** Page type → URL path mapping. Chat is the root, everything else maps to /slug. */ +export const ROUTES: Record = { + 'chat': '/', + 'portfolio': '/portfolio', + 'events': '/events', + 'heartbeat': '/heartbeat', + 'data-sources': '/data-sources', + 'connectors': '/connectors', + 'tools': '/tools', + 'trading': '/trading', + 'ai-provider': '/ai-provider', + 'settings': '/settings', + 'dev': '/dev', +} + export function App() { - const [page, setPage] = useState('chat') const [sseConnected, setSseConnected] = useState(false) const [sidebarOpen, setSidebarOpen] = useState(false) @@ -28,8 +41,6 @@ export function App() {
setSidebarOpen(false)} /> @@ -47,18 +58,20 @@ export function App() { Open Alice
- {page === 'chat' && } - {page === 'portfolio' && } - {page === 'events' && } - {page === 'heartbeat' && } - {page === 'data-sources' && } - {page === 'connectors' && } - {page.startsWith('trading/') && } - {page.startsWith('securities/') && } - {page === 'ai-provider' && } - {page === 'settings' && } - {page === 'tools' && } - {page === 'dev' && } + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + ) diff --git a/ui/src/api/index.ts b/ui/src/api/index.ts index d285068f..7f65d65e 100644 --- a/ui/src/api/index.ts +++ b/ui/src/api/index.ts @@ -35,10 +35,9 @@ export type { CronSchedule, CronJobState, CronJob, - CryptoAccount, - CryptoPosition, - SecAccount, - SecHolding, + TradingAccount, + AccountInfo, + Position, WalletCommitLog, ReconnectResult, ConnectorsConfig, diff --git a/ui/src/api/trading.ts b/ui/src/api/trading.ts index 125f787a..0e8fdf2f 100644 --- a/ui/src/api/trading.ts +++ b/ui/src/api/trading.ts @@ -1,68 +1,97 @@ import { fetchJson } from './client' -import type { CryptoAccount, CryptoPosition, SecAccount, SecHolding, WalletCommitLog, ReconnectResult } from './types' +import type { TradingAccount, AccountInfo, Position, WalletCommitLog, ReconnectResult, PlatformConfig, AccountConfig } from './types' + +// ==================== Unified Trading API ==================== export const tradingApi = { - // ==================== Reconnect ==================== + // ==================== Accounts ==================== - async reconnectCrypto(): Promise { - const res = await fetch('/api/crypto/reconnect', { method: 'POST' }) - return res.json() + async listAccounts(): Promise<{ accounts: TradingAccount[] }> { + return fetchJson('/api/trading/accounts') + }, + + async equity(): Promise<{ totalEquity: number; totalCash: number; totalUnrealizedPnL: number; totalRealizedPnL: number; accounts: Array<{ id: string; label: string; equity: number; cash: number }> }> { + return fetchJson('/api/trading/equity') }, - async reconnectSecurities(): Promise { - const res = await fetch('/api/securities/reconnect', { method: 'POST' }) + // ==================== Per-account ==================== + + async reconnectAccount(accountId: string): Promise { + const res = await fetch(`/api/trading/accounts/${accountId}/reconnect`, { method: 'POST' }) return res.json() }, - // ==================== Crypto Data ==================== + async accountInfo(accountId: string): Promise { + return fetchJson(`/api/trading/accounts/${accountId}/account`) + }, - async cryptoAccount(): Promise { - return fetchJson('/api/crypto/account') + async positions(accountId: string): Promise<{ positions: Position[] }> { + return fetchJson(`/api/trading/accounts/${accountId}/positions`) }, - async cryptoPositions(): Promise<{ positions: CryptoPosition[] }> { - return fetchJson('/api/crypto/positions') + async orders(accountId: string): Promise<{ orders: unknown[] }> { + return fetchJson(`/api/trading/accounts/${accountId}/orders`) }, - async cryptoOrders(): Promise<{ orders: unknown[] }> { - return fetchJson('/api/crypto/orders') + async marketClock(accountId: string): Promise<{ isOpen: boolean; nextOpen: string; nextClose: string }> { + return fetchJson(`/api/trading/accounts/${accountId}/market-clock`) }, - async cryptoWalletLog(limit = 20, symbol?: string): Promise<{ commits: WalletCommitLog[] }> { + async walletLog(accountId: string, limit = 20, symbol?: string): Promise<{ commits: WalletCommitLog[] }> { const params = new URLSearchParams({ limit: String(limit) }) if (symbol) params.set('symbol', symbol) - return fetchJson(`/api/crypto/wallet/log?${params}`) + return fetchJson(`/api/trading/accounts/${accountId}/wallet/log?${params}`) }, - async cryptoWalletShow(hash: string): Promise { - return fetchJson(`/api/crypto/wallet/show/${hash}`) + async walletShow(accountId: string, hash: string): Promise { + return fetchJson(`/api/trading/accounts/${accountId}/wallet/show/${hash}`) }, - // ==================== Securities Data ==================== + // ==================== Trading Config CRUD ==================== - async secAccount(): Promise { - return fetchJson('/api/securities/account') + async loadTradingConfig(): Promise<{ platforms: PlatformConfig[]; accounts: AccountConfig[] }> { + return fetchJson('/api/trading/config') }, - async secPortfolio(): Promise<{ holdings: SecHolding[] }> { - return fetchJson('/api/securities/portfolio') - }, - - async secOrders(): Promise<{ orders: unknown[] }> { - return fetchJson('/api/securities/orders') + async upsertPlatform(platform: PlatformConfig): Promise { + const res = await fetch(`/api/trading/config/platforms/${platform.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(platform), + }) + if (!res.ok) { + const body = await res.json().catch(() => ({})) + throw new Error(body.error || `Failed to save platform (${res.status})`) + } + return res.json() }, - async secMarketClock(): Promise<{ isOpen: boolean; nextOpen: string; nextClose: string }> { - return fetchJson('/api/securities/market-clock') + async deletePlatform(id: string): Promise { + const res = await fetch(`/api/trading/config/platforms/${id}`, { method: 'DELETE' }) + if (!res.ok) { + const body = await res.json().catch(() => ({})) + throw new Error(body.error || `Failed to delete platform (${res.status})`) + } }, - async secWalletLog(limit = 20, symbol?: string): Promise<{ commits: WalletCommitLog[] }> { - const params = new URLSearchParams({ limit: String(limit) }) - if (symbol) params.set('symbol', symbol) - return fetchJson(`/api/securities/wallet/log?${params}`) + async upsertAccount(account: AccountConfig): Promise { + const res = await fetch(`/api/trading/config/accounts/${account.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(account), + }) + if (!res.ok) { + const body = await res.json().catch(() => ({})) + throw new Error(body.error || `Failed to save account (${res.status})`) + } + return res.json() }, - async secWalletShow(hash: string): Promise { - return fetchJson(`/api/securities/wallet/show/${hash}`) + async deleteAccount(id: string): Promise { + const res = await fetch(`/api/trading/config/accounts/${id}`, { method: 'DELETE' }) + if (!res.ok) { + const body = await res.json().catch(() => ({})) + throw new Error(body.error || `Failed to delete account (${res.status})`) + } }, } diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index 12eec866..ca67a3ab 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -111,40 +111,25 @@ export interface CronJob { // ==================== Trading ==================== -export interface CryptoAccount { - balance: number - totalMargin: number - unrealizedPnL: number - equity: number - realizedPnL: number - totalPnL: number -} - -export interface CryptoPosition { - symbol: string - side: 'long' | 'short' - size: number - entryPrice: number - leverage: number - margin: number - liquidationPrice?: number - markPrice: number - unrealizedPnL: number - positionValue: number +export interface TradingAccount { + id: string + provider: string + label: string } -export interface SecAccount { +export interface AccountInfo { cash: number - portfolioValue: number equity: number - buyingPower: number unrealizedPnL: number realizedPnL: number - dayTradeCount: number + portfolioValue?: number + buyingPower?: number + totalMargin?: number + dayTradeCount?: number } -export interface SecHolding { - symbol: string +export interface Position { + contract: { aliceId?: string; symbol?: string; secType?: string; exchange?: string; currency?: string } side: 'long' | 'short' qty: number avgEntryPrice: number @@ -153,6 +138,9 @@ export interface SecHolding { unrealizedPnL: number unrealizedPnLPercent: number costBasis: number + leverage: number + margin?: number + liquidationPrice?: number } export interface WalletCommitLog { @@ -168,3 +156,39 @@ export interface ReconnectResult { error?: string message?: string } + +// ==================== Trading Config ==================== + +export interface CcxtPlatformConfig { + id: string + label?: string + type: 'ccxt' + exchange: string + sandbox: boolean + demoTrading: boolean + defaultMarketType: 'spot' | 'swap' +} + +export interface AlpacaPlatformConfig { + id: string + label?: string + type: 'alpaca' + paper: boolean +} + +export type PlatformConfig = CcxtPlatformConfig | AlpacaPlatformConfig + +export interface AccountConfig { + id: string + platformId: string + label?: string + apiKey?: string + apiSecret?: string + password?: string + guards: GuardEntry[] +} + +export interface GuardEntry { + type: string + options: Record +} diff --git a/ui/src/components/ReconnectButton.tsx b/ui/src/components/ReconnectButton.tsx index e06e1478..f4f836f0 100644 --- a/ui/src/components/ReconnectButton.tsx +++ b/ui/src/components/ReconnectButton.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef } from 'react' import { api } from '../api' -export function ReconnectButton({ variant }: { variant: 'crypto' | 'securities' }) { +export function ReconnectButton({ accountId }: { accountId: string }) { const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle') const [message, setMessage] = useState('') const timerRef = useRef | null>(null) @@ -12,9 +12,7 @@ export function ReconnectButton({ variant }: { variant: 'crypto' | 'securities' setStatus('loading') setMessage('') try { - const result = variant === 'crypto' - ? await api.trading.reconnectCrypto() - : await api.trading.reconnectSecurities() + const result = await api.trading.reconnectAccount(accountId) if (result.success) { setStatus('success') setMessage(result.message || 'Connected') diff --git a/ui/src/components/SDKSelector.tsx b/ui/src/components/SDKSelector.tsx index 0475ba70..08acd719 100644 --- a/ui/src/components/SDKSelector.tsx +++ b/ui/src/components/SDKSelector.tsx @@ -189,6 +189,23 @@ export const SECURITIES_SDK_OPTIONS: SDKOption[] = [ }, ] +export const PLATFORM_TYPE_OPTIONS: SDKOption[] = [ + { + id: 'ccxt', + name: 'CCXT (Crypto)', + description: 'Unified API for 100+ crypto exchanges. Supports Binance, Bybit, OKX, Coinbase, and more.', + badge: 'CC', + badgeColor: 'text-accent', + }, + { + id: 'alpaca', + name: 'Alpaca (Securities)', + description: 'Commission-free US equities and ETFs with fractional share support.', + badge: 'AL', + badgeColor: 'text-green', + }, +] + export const DATASOURCE_OPTIONS: SDKOption[] = [ { id: 'openbb', diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index eeb7fb62..98e9fd48 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -1,10 +1,9 @@ -import { useCallback, type ReactNode } from 'react' -import type { Page } from '../App' +import { type ReactNode } from 'react' +import { Link, useLocation } from 'react-router-dom' +import { type Page, ROUTES } from '../App' interface SidebarProps { sseConnected: boolean - currentPage: Page - onNavigate: (page: Page) => void open: boolean onClose: () => void } @@ -110,9 +109,9 @@ const NAV_ITEMS: NavItem[] = [ ), }, { - prefix: 'trading', - label: 'Crypto', - icon: (active) => ( + page: 'trading' as const, + label: 'Trading', + icon: (active: boolean) => ( @@ -121,24 +120,6 @@ const NAV_ITEMS: NavItem[] = [ ), - children: [ - { page: 'trading/connection', label: 'Connection' }, - { page: 'trading/guards', label: 'Guards' }, - ], - }, - { - prefix: 'securities', - label: 'Securities', - icon: (active) => ( - - - - - ), - children: [ - { page: 'securities/connection', label: 'Connection' }, - { page: 'securities/guards', label: 'Guards' }, - ], }, { page: 'ai-provider', @@ -172,16 +153,23 @@ const NAV_ITEMS: NavItem[] = [ }, ] +// ==================== Helpers ==================== + +/** Derive active page from current URL path */ +function pathToPage(pathname: string): Page | null { + for (const [page, path] of Object.entries(ROUTES) as [Page, string][]) { + if (path === pathname) return page + // Match root path for chat + if (page === 'chat' && pathname === '/') return 'chat' + } + return null +} + // ==================== Sidebar ==================== -export function Sidebar({ sseConnected, currentPage, onNavigate, open, onClose }: SidebarProps) { - const handleNav = useCallback( - (page: Page) => { - onNavigate(page) - onClose() - }, - [onNavigate, onClose], - ) +export function Sidebar({ sseConnected, open, onClose }: SidebarProps) { + const location = useLocation() + const currentPage = pathToPage(location.pathname) return ( <> @@ -216,20 +204,13 @@ export function Sidebar({ sseConnected, currentPage, onNavigate, open, onClose } diff --git a/ui/src/hooks/useTradingConfig.ts b/ui/src/hooks/useTradingConfig.ts new file mode 100644 index 00000000..f432d5ad --- /dev/null +++ b/ui/src/hooks/useTradingConfig.ts @@ -0,0 +1,87 @@ +import { useState, useEffect, useCallback } from 'react' +import { api } from '../api' +import type { PlatformConfig, AccountConfig, ReconnectResult } from '../api/types' + +export interface UseTradingConfigResult { + platforms: PlatformConfig[] + accounts: AccountConfig[] + loading: boolean + error: string | null + + savePlatform: (p: PlatformConfig) => Promise + deletePlatform: (id: string) => Promise + saveAccount: (a: AccountConfig) => Promise + deleteAccount: (id: string) => Promise + reconnectAccount: (id: string) => Promise + refresh: () => Promise +} + +export function useTradingConfig(): UseTradingConfigResult { + const [platforms, setPlatforms] = useState([]) + const [accounts, setAccounts] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const load = useCallback(async () => { + try { + setLoading(true) + setError(null) + const data = await api.trading.loadTradingConfig() + setPlatforms(data.platforms) + setAccounts(data.accounts) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { load() }, [load]) + + const savePlatform = useCallback(async (p: PlatformConfig) => { + await api.trading.upsertPlatform(p) + setPlatforms((prev) => { + const idx = prev.findIndex((x) => x.id === p.id) + if (idx >= 0) { + const next = [...prev] + next[idx] = p + return next + } + return [...prev, p] + }) + }, []) + + const deletePlatform = useCallback(async (id: string) => { + await api.trading.deletePlatform(id) + setPlatforms((prev) => prev.filter((p) => p.id !== id)) + }, []) + + const saveAccount = useCallback(async (a: AccountConfig) => { + await api.trading.upsertAccount(a) + setAccounts((prev) => { + const idx = prev.findIndex((x) => x.id === a.id) + if (idx >= 0) { + const next = [...prev] + next[idx] = a + return next + } + return [...prev, a] + }) + }, []) + + const deleteAccount = useCallback(async (id: string) => { + await api.trading.deleteAccount(id) + setAccounts((prev) => prev.filter((a) => a.id !== id)) + }, []) + + const reconnectAccount = useCallback(async (id: string): Promise => { + return api.trading.reconnectAccount(id) + }, []) + + return { + platforms, accounts, loading, error, + savePlatform, deletePlatform, + saveAccount, deleteAccount, + reconnectAccount, refresh: load, + } +} diff --git a/ui/src/main.tsx b/ui/src/main.tsx index a6333103..c2e3ddbe 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -1,10 +1,13 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' import { App } from './App' import './index.css' createRoot(document.getElementById('root')!).render( - + + + , ) diff --git a/ui/src/pages/PortfolioPage.tsx b/ui/src/pages/PortfolioPage.tsx index 93bd71ad..91b6cab8 100644 --- a/ui/src/pages/PortfolioPage.tsx +++ b/ui/src/pages/PortfolioPage.tsx @@ -1,28 +1,32 @@ import { useState, useEffect, useCallback } from 'react' -import { api, type CryptoAccount, type CryptoPosition, type SecAccount, type SecHolding, type WalletCommitLog } from '../api' +import { api, type Position, type WalletCommitLog } from '../api' // ==================== Types ==================== -interface PortfolioData { - crypto: { - account: CryptoAccount | null - positions: CryptoPosition[] - walletLog: WalletCommitLog[] - error?: string - } - securities: { - account: SecAccount | null - holdings: SecHolding[] - walletLog: WalletCommitLog[] - error?: string - } +interface AggregatedEquity { + totalEquity: number + totalCash: number + totalUnrealizedPnL: number + totalRealizedPnL: number + accounts: Array<{ id: string; label: string; equity: number; cash: number }> } -const EMPTY: PortfolioData = { - crypto: { account: null, positions: [], walletLog: [] }, - securities: { account: null, holdings: [], walletLog: [] }, +interface AccountData { + id: string + provider: string + label: string + positions: Position[] + walletLog: WalletCommitLog[] + error?: string +} + +interface PortfolioData { + equity: AggregatedEquity | null + accounts: AccountData[] } +const EMPTY: PortfolioData = { equity: null, accounts: [] } + // ==================== Page ==================== export function PortfolioPage() { @@ -32,14 +36,8 @@ export function PortfolioPage() { const refresh = useCallback(async () => { setLoading(true) - - // Fetch crypto and securities data in parallel - const [cryptoResult, secResult] = await Promise.all([ - fetchCryptoData(), - fetchSecuritiesData(), - ]) - - setData({ crypto: cryptoResult, securities: secResult }) + const result = await fetchPortfolioData() + setData(result) setLastRefresh(new Date()) setLoading(false) }, []) @@ -52,6 +50,20 @@ export function PortfolioPage() { return () => clearInterval(interval) }, [refresh]) + const allPositions = data.accounts.flatMap(a => + a.positions.map(p => ({ ...p, accountLabel: a.label, accountProvider: a.provider })), + ) + const allWalletLogs = data.accounts.flatMap(a => + a.walletLog.map(c => ({ ...c, accountLabel: a.label, accountProvider: a.provider })), + ) + + // Merge equity per-account data with provider info + per-account unrealizedPnL from positions + const accountSources = (data.equity?.accounts ?? []).map(eq => { + const acct = data.accounts.find(a => a.id === eq.id) + const unrealizedPnL = acct?.positions.reduce((sum, p) => sum + p.unrealizedPnL, 0) ?? 0 + return { ...eq, provider: acct?.provider ?? '', unrealizedPnL, error: acct?.error } + }) + return (
{/* Header */} @@ -60,7 +72,7 @@ export function PortfolioPage() {

Portfolio

- Live portfolio overview across crypto and securities. + Live portfolio overview across all trading accounts. {lastRefresh && ( Updated {lastRefresh.toLocaleTimeString()} @@ -80,32 +92,30 @@ export function PortfolioPage() { {/* Content */}

-
- {/* Account Summary Cards */} -
- - -
+
+ - {/* Positions / Holdings */} - {data.crypto.positions.length > 0 && ( - + {accountSources.length > 0 && ( + )} - {data.securities.holdings.length > 0 && ( - + + {allPositions.length > 0 && ( + )} - {/* Empty state */} - {!data.crypto.account && !data.securities.account && !loading && ( + {/* Empty states */} + {data.accounts.length === 0 && !loading && (
-

No trading engines connected.

-

Configure exchange connections in the Crypto or Securities pages.

+

No trading accounts connected.

+

Configure connections in the Trading page.

)} + {data.accounts.length > 0 && allPositions.length === 0 && !loading && ( +

No open positions.

+ )} - {/* Wallet Logs (trade history) */} - {(data.crypto.walletLog.length > 0 || data.securities.walletLog.length > 0) && ( - + {allWalletLogs.length > 0 && ( + )}
@@ -115,166 +125,151 @@ export function PortfolioPage() { // ==================== Data Fetching ==================== -async function fetchCryptoData(): Promise { +async function fetchPortfolioData(): Promise { try { - const [account, positionsResp, walletResp] = await Promise.all([ - api.trading.cryptoAccount(), - api.trading.cryptoPositions(), - api.trading.cryptoWalletLog(10), + const [equityResult, accountsResult] = await Promise.allSettled([ + api.trading.equity(), + api.trading.listAccounts(), ]) - return { account, positions: positionsResp.positions, walletLog: walletResp.commits } - } catch { - return { account: null, positions: [], walletLog: [], error: 'Not connected' } - } -} -async function fetchSecuritiesData(): Promise { - try { - const [account, portfolioResp, walletResp] = await Promise.all([ - api.trading.secAccount(), - api.trading.secPortfolio(), - api.trading.secWalletLog(10), - ]) - return { account, holdings: portfolioResp.holdings, walletLog: walletResp.commits } + const equity = equityResult.status === 'fulfilled' ? equityResult.value : null + const accountsList = accountsResult.status === 'fulfilled' ? accountsResult.value.accounts : [] + + const accounts = await Promise.all( + accountsList.map(async (acct): Promise => { + try { + const [posResp, logResp] = await Promise.all([ + api.trading.positions(acct.id), + api.trading.walletLog(acct.id, 10), + ]) + return { ...acct, positions: posResp.positions, walletLog: logResp.commits } + } catch { + return { ...acct, positions: [], walletLog: [], error: 'Not connected' } + } + }), + ) + + return { equity, accounts } } catch { - return { account: null, holdings: [], walletLog: [], error: 'Not connected' } + return EMPTY } } -// ==================== Account Cards ==================== +// ==================== Hero Metrics ==================== -function CryptoAccountCard({ account, error }: { account: CryptoAccount | null; error?: string }) { - return ( -
-
-
-

Crypto

- {error && {error}} +function HeroMetrics({ equity }: { equity: AggregatedEquity | null }) { + if (!equity) { + return ( +
+

Unable to load portfolio data.

- {account ? ( -
- - - - -
- ) : ( -

No data available

- )} -
- ) -} + ) + } -function SecAccountCard({ account, error }: { account: SecAccount | null; error?: string }) { return ( -
-
-
-

Securities

- {error && {error}} +
+
+ + + +
- {account ? ( -
- - - - -
- ) : ( -

No data available

- )}
) } -function MetricItem({ label, value, pnl }: { label: string; value: string; pnl?: number }) { +function HeroItem({ label, value, pnl }: { label: string; value: string; pnl?: number }) { const color = pnl == null ? 'text-text' : pnl >= 0 ? 'text-green' : 'text-red' return (
-

{label}

-

{value}

+

{label}

+

{value}

) } -// ==================== Positions Table ==================== +// ==================== Account Strip ==================== + +const PROVIDER_COLORS: Record = { + ccxt: 'bg-accent', + alpaca: 'bg-green', +} -function PositionsTable({ positions }: { positions: CryptoPosition[] }) { +function AccountStrip({ sources }: { sources: Array<{ id: string; label: string; provider: string; equity: number; unrealizedPnL: number; error?: string }> }) { return ( -
-

- Crypto Positions -

-
- - - - - - - - - - - - - - {positions.map((p, i) => ( - - - - - - - - - - ))} - -
SymbolSideSizeEntryMarkLevPnL
{p.symbol} - {p.side} - {fmtNum(p.size)}{fmt(p.entryPrice)}{fmt(p.markPrice)}{p.leverage}x= 0 ? 'text-green' : 'text-red'}`}> - {fmtPnl(p.unrealizedPnL)} -
-
+
+ {sources.map(s => { + const dotColor = PROVIDER_COLORS[s.provider] || 'bg-text-muted' + return ( +
+
+ {s.label} + {fmt(s.equity)} + {s.unrealizedPnL !== 0 && ( + = 0 ? 'text-green' : 'text-red'}> + {fmtPnl(s.unrealizedPnL)} + + )} + {s.error && {s.error}} +
+ ) + })}
) } -// ==================== Holdings Table ==================== +// ==================== Positions Table ==================== + +interface PositionWithAccount extends Position { + accountLabel: string + accountProvider: string +} + +function PositionsTable({ positions }: { positions: PositionWithAccount[] }) { + const hasLeverage = positions.some(p => p.leverage > 1) -function HoldingsTable({ holdings }: { holdings: SecHolding[] }) { return (

- Securities Holdings + Positions

-
+
+ - + + {hasLeverage && } + - {holdings.map((h, i) => ( + {positions.map((p, i) => ( - - - - - - + + + + + {hasLeverage && } + + + - ))} @@ -287,15 +282,17 @@ function HoldingsTable({ holdings }: { holdings: SecHolding[] }) { // ==================== Trade Log ==================== -function TradeLog({ crypto, securities }: { crypto: WalletCommitLog[]; securities: WalletCommitLog[] }) { - // Merge and sort by timestamp descending - const all = [ - ...crypto.map((c) => ({ ...c, source: 'crypto' as const })), - ...securities.map((c) => ({ ...c, source: 'securities' as const })), - ].sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) - .slice(0, 15) +interface CommitWithAccount extends WalletCommitLog { + accountLabel: string + accountProvider: string +} + +function TradeLog({ commits }: { commits: CommitWithAccount[] }) { + const sorted = [...commits] + .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) + .slice(0, 10) - if (all.length === 0) return null + if (sorted.length === 0) return null return (
@@ -303,38 +300,43 @@ function TradeLog({ crypto, securities }: { crypto: WalletCommitLog[]; securitie Recent Trades
- {all.map((commit) => ( -
-
- - {commit.source === 'crypto' ? 'CRYPTO' : 'SEC'} - -
-

{commit.message}

-
- {commit.hash} - - {new Date(commit.timestamp).toLocaleString()} - -
- {commit.operations.length > 0 && ( -
- {commit.operations.map((op, i) => ( - - {op.symbol} {op.change} - - {op.status} - - - ))} + {sorted.map((commit) => { + const badgeColor = commit.accountProvider === 'ccxt' + ? 'bg-accent/15 text-accent' + : commit.accountProvider === 'alpaca' + ? 'bg-green/15 text-green' + : 'bg-bg-tertiary text-text-muted' + return ( +
+
+ + {commit.accountLabel} + +
+

{commit.message}

+
+ {commit.hash} + + {new Date(commit.timestamp).toLocaleString()} +
- )} + {commit.operations.length > 0 && ( +
+ {commit.operations.map((op, i) => ( + + {op.symbol} {op.change} + + {op.status} + + + ))} +
+ )} +
-
- ))} + ) + })}
) diff --git a/ui/src/pages/SecuritiesPage.tsx b/ui/src/pages/SecuritiesPage.tsx deleted file mode 100644 index 15674572..00000000 --- a/ui/src/pages/SecuritiesPage.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import { useConfigPage } from '../hooks/useConfigPage' -import { SaveIndicator } from '../components/SaveIndicator' -import { Toggle } from '../components/Toggle' -import { Section, Field, inputClass } from '../components/form' -import { GuardsSection, SECURITIES_GUARD_TYPES, type GuardEntry } from '../components/guards' -import { SDKSelector, SECURITIES_SDK_OPTIONS } from '../components/SDKSelector' -import { ReconnectButton } from '../components/ReconnectButton' -import type { AppConfig } from '../api' - -interface SecuritiesConfig { - provider: { - type: 'alpaca' | 'none' - apiKey?: string - secretKey?: string - paper?: boolean - } - guards: GuardEntry[] -} - -export function SecuritiesPage({ tab }: { tab: string }) { - const { config, status, loadError, updateConfig, updateConfigImmediate, retry } = - useConfigPage({ - section: 'securities', - extract: (full: AppConfig) => (full as Record).securities as SecuritiesConfig, - }) - - const enabled = config?.provider.type !== 'none' - - const handleToggle = (on: boolean) => { - if (on) { - updateConfigImmediate({ provider: { ...config!.provider, type: 'alpaca' } }) - } else { - updateConfigImmediate({ provider: { type: 'none' } }) - } - } - - return ( -
- {/* Header */} -
-
-
-

Securities Trading

-

- {tab === 'connection' - ? 'Broker connection and SDK configuration for US equities.' - : 'Trading guards validate operations before they reach the broker.'} -

-
- -
-
- - {/* Content */} -
- {config && ( -
- {tab === 'connection' && ( - <> - {/* Enable / SDK selection */} -
-
-
-

Enable Securities Trading

-

- When disabled, the securities trading engine and all related tools are unloaded. -

-
- -
- - {enabled && ( -
-

Select a broker SDK to connect with your brokerage.

- {/* future: switch SDK */}} - /> -
- )} -
- - {/* Broker config — only when enabled */} - {enabled && ( - - )} - - )} - - {tab === 'guards' && enabled && ( - updateConfig({ guards })} - onChangeImmediate={(guards) => updateConfigImmediate({ guards })} - /> - )} - - {tab === 'guards' && !enabled && ( -

- Enable securities trading in the Connection tab to configure guards. -

- )} -
- )} - {loadError &&

Failed to load configuration.

} -
-
- ) -} - -// ==================== Broker Section (Alpaca-specific) ==================== - -interface BrokerSectionProps { - config: SecuritiesConfig - onChange: (patch: Partial) => void - onChangeImmediate: (patch: Partial) => void -} - -function BrokerSection({ config, onChange, onChangeImmediate }: BrokerSectionProps) { - const patchProvider = (field: string, value: unknown, immediate: boolean) => { - const patch = { - provider: { ...config.provider, type: 'alpaca' as const, [field]: value }, - } - immediate ? onChangeImmediate(patch) : onChange(patch) - } - - return ( -
- - patchProvider('apiKey', e.target.value, false)} - placeholder="Not configured" - /> - - - patchProvider('secretKey', e.target.value, false)} - placeholder="Not configured" - /> - - -

- When enabled, orders are routed to Alpaca's paper trading environment. Disable for live trading. -

- -
- ) -} diff --git a/ui/src/pages/TradingPage.tsx b/ui/src/pages/TradingPage.tsx index c9c69806..b4f19fc6 100644 --- a/ui/src/pages/TradingPage.tsx +++ b/ui/src/pages/TradingPage.tsx @@ -1,210 +1,621 @@ -import { useConfigPage } from '../hooks/useConfigPage' -import { SaveIndicator } from '../components/SaveIndicator' -import { Toggle } from '../components/Toggle' +import { useState, useEffect, useCallback } from 'react' import { Section, Field, inputClass } from '../components/form' -import { GuardsSection, CRYPTO_GUARD_TYPES, type GuardEntry } from '../components/guards' -import { SDKSelector, CRYPTO_SDK_OPTIONS } from '../components/SDKSelector' +import { Toggle } from '../components/Toggle' +import { GuardsSection, CRYPTO_GUARD_TYPES, SECURITIES_GUARD_TYPES } from '../components/guards' +import { SDKSelector, PLATFORM_TYPE_OPTIONS } from '../components/SDKSelector' import { ReconnectButton } from '../components/ReconnectButton' -import type { AppConfig } from '../api' - -interface CryptoConfig { - provider: { - type: 'ccxt' | 'none' - exchange?: string - apiKey?: string - apiSecret?: string - password?: string - sandbox?: boolean - demoTrading?: boolean - defaultMarketType?: 'spot' | 'swap' +import { useTradingConfig } from '../hooks/useTradingConfig' +import type { PlatformConfig, CcxtPlatformConfig, AlpacaPlatformConfig, AccountConfig } from '../api/types' + +// ==================== Dialog state ==================== + +type DialogState = + | { kind: 'edit'; accountId: string } + | { kind: 'add' } + | null + +// ==================== Page ==================== + +export function TradingPage() { + const tc = useTradingConfig() + const [dialog, setDialog] = useState(null) + + // Close dialog if the selected account was deleted + useEffect(() => { + if (dialog?.kind === 'edit') { + if (!tc.accounts.some((a) => a.id === dialog.accountId)) setDialog(null) + } + }, [tc.accounts, dialog]) + + if (tc.loading) return + if (tc.error) { + return ( + +

{tc.error}

+ +
+ ) } - guards: GuardEntry[] -} -export function TradingPage({ tab }: { tab: string }) { - const { config, status, loadError, updateConfig, updateConfigImmediate, retry } = - useConfigPage({ - section: 'crypto', - extract: (full: AppConfig) => (full as Record).crypto as CryptoConfig, - }) - - const enabled = config?.provider.type !== 'none' - - const handleToggle = (on: boolean) => { - if (on) { - // Re-enable with CCXT defaults - updateConfigImmediate({ provider: { ...config!.provider, type: 'ccxt' } }) - } else { - updateConfigImmediate({ provider: { type: 'none' } }) + const getPlatform = (platformId: string) => tc.platforms.find((p) => p.id === platformId) + + const deleteAccountWithPlatform = async (accountId: string) => { + const account = tc.accounts.find((a) => a.id === accountId) + if (!account) return + const platformId = account.platformId + await tc.deleteAccount(accountId) + const remaining = tc.accounts.filter((a) => a.id !== accountId && a.platformId === platformId) + if (remaining.length === 0) { + try { await tc.deletePlatform(platformId) } catch { /* best effort */ } } + setDialog(null) } return (
{/* Header */}
-
-
-

Crypto Trading

-

- {tab === 'connection' - ? 'Exchange connection and SDK configuration for cryptocurrency markets.' - : 'Trading guards validate operations before they reach the exchange.'} -

-
- +
+

Trading

+

Configure your trading accounts.

{/* Content */}
- {config && ( -
- {tab === 'connection' && ( - <> - {/* Enable / SDK selection */} -
-
-
-

Enable Crypto Trading

-

- When disabled, the crypto trading engine and all related tools are unloaded. -

-
- -
- - {enabled && ( -
-

Select a trading SDK to connect with your exchange.

- {/* future: switch SDK */}} - /> -
- )} -
- - {/* Exchange config — only when enabled */} - {enabled && ( - - )} - - )} +
+ setDialog({ kind: 'edit', accountId: id })} + /> - {tab === 'guards' && enabled && ( - updateConfig({ guards })} - onChangeImmediate={(guards) => updateConfigImmediate({ guards })} - /> - )} + +
+
+ + {/* Create Wizard */} + {dialog?.kind === 'add' && ( + a.id)} + onSave={async (platform, account) => { + await tc.savePlatform(platform) + await tc.saveAccount(account) + setDialog(null) + }} + onClose={() => setDialog(null)} + /> + )} + + {/* Edit Dialog */} + {dialog?.kind === 'edit' && (() => { + const account = tc.accounts.find((a) => a.id === dialog.accountId) + const platform = account ? getPlatform(account.platformId) : undefined + if (!account || !platform) return null + return ( + deleteAccountWithPlatform(account.id)} + onClose={() => setDialog(null)} + /> + ) + })()} +
+ ) +} + +// ==================== Page Shell ==================== + +function PageShell({ subtitle, children }: { subtitle: string; children?: React.ReactNode }) { + return ( +
+
+
+

Trading

+

{subtitle}

+
+
+
{children}
+
+ ) +} + +// ==================== Dialog ==================== + +function Dialog({ onClose, width, children }: { + onClose: () => void + width?: string + children: React.ReactNode +}) { + const handleKeyDown = useCallback((e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + }, [onClose]) + + useEffect(() => { + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [handleKeyDown]) + + return ( +
+ {/* Backdrop */} +
+ {/* Content */} +
+ {children} +
+
+ ) +} + +// ==================== Accounts Table ==================== + +function AccountsTable({ accounts, platforms, onSelect }: { + accounts: AccountConfig[] + platforms: PlatformConfig[] + onSelect: (id: string) => void +}) { + const getPlatform = (platformId: string) => platforms.find((p) => p.id === platformId) + + const getConnectionLabel = (account: AccountConfig) => { + const p = getPlatform(account.platformId) + if (!p) return '—' + if (p.type === 'ccxt') { + const parts = [p.exchange] + if (p.defaultMarketType === 'swap') parts.push('swap') + else parts.push('spot') + return parts.join(' \u00b7 ') + } + return p.paper ? 'paper' : 'live' + } + + if (accounts.length === 0) { + return ( +
+

No accounts configured.

+

Click "+ Add Account" to connect your first trading account.

+
+ ) + } + + return ( +
+
SymbolSide QtyAvg EntryEntry CurrentLevCost Basis Mkt Value PnL PnL %
{h.symbol}{fmtNum(h.qty)}{fmt(h.avgEntryPrice)}{fmt(h.currentPrice)}{fmt(h.marketValue)}= 0 ? 'text-green' : 'text-red'}`}> - {fmtPnl(h.unrealizedPnL)} + + {p.contract.symbol} + {p.accountLabel} + + {p.side} + {fmtNum(p.qty)}{fmt(p.avgEntryPrice)}{fmt(p.currentPrice)}{p.leverage}x{fmt(p.costBasis)}{fmt(p.marketValue)}= 0 ? 'text-green' : 'text-red'}`}> + {fmtPnl(p.unrealizedPnL)} = 0 ? 'text-green' : 'text-red'}`}> - {h.unrealizedPnLPercent >= 0 ? '+' : ''}{h.unrealizedPnLPercent.toFixed(2)}% + = 0 ? 'text-green' : 'text-red'}`}> + {p.unrealizedPnLPercent >= 0 ? '+' : ''}{p.unrealizedPnLPercent.toFixed(2)}%
+ + + + + + + + + + {accounts.map((account) => { + const p = getPlatform(account.platformId) + const badge = p?.type === 'ccxt' + ? { text: 'CC', color: 'text-accent bg-accent/10' } + : { text: 'AL', color: 'text-green bg-green/10' } + + return ( + onSelect(account.id)} + className="cursor-pointer transition-colors hover:bg-bg-tertiary/30" + > + + + + + + ) + })} + +
AccountConnectionGuards
+ + {badge.text} + + {account.id}{getConnectionLabel(account)} + {account.guards.length > 0 ? account.guards.length : '—'} +
+
+ ) +} + +// ==================== Create Wizard ==================== + +function CreateWizard({ existingAccountIds, onSave, onClose }: { + existingAccountIds: string[] + onSave: (platform: PlatformConfig, account: AccountConfig) => Promise + onClose: () => void +}) { + const [step, setStep] = useState(1) + const [type, setType] = useState<'ccxt' | 'alpaca' | null>(null) + + // Step 2 fields + const [id, setId] = useState('') + const [exchange, setExchange] = useState('binance') + const [marketType, setMarketType] = useState<'swap' | 'spot'>('swap') + const [sandbox, setSandbox] = useState(false) + const [demoTrading, setDemoTrading] = useState(false) + const [paper, setPaper] = useState(true) + + // Step 3 fields + const [apiKey, setApiKey] = useState('') + const [apiSecret, setApiSecret] = useState('') + const [password, setPassword] = useState('') + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + + const defaultId = type === 'ccxt' ? `${exchange}-main` : 'alpaca-paper' + const finalId = id.trim() || defaultId + + const handleSelectType = (t: string) => { + setType(t as 'ccxt' | 'alpaca') + setStep(2) + } + + const handleNext = () => { + if (existingAccountIds.includes(finalId)) { + setError(`Account "${finalId}" already exists`) + return + } + setError('') + setStep(3) + } + + const handleCreate = async () => { + setSaving(true); setError('') + try { + const platformId = `${finalId}-platform` + const platform: PlatformConfig = type === 'ccxt' + ? { id: platformId, type: 'ccxt', exchange, sandbox, demoTrading, defaultMarketType: marketType } + : { id: platformId, type: 'alpaca', paper } + const account: AccountConfig = { + id: finalId, platformId, + ...(apiKey && { apiKey }), + ...(apiSecret && { apiSecret }), + ...(password && type === 'ccxt' && { password }), + guards: [], + } + await onSave(platform, account) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create account') + setSaving(false) + } + } + + return ( + + {/* Header */} +
+

New Account

+ Step {step}/3 +
+ + {/* Body */} +
+ {step === 1 && ( +
+

Choose your platform

+ +
+ )} - {tab === 'guards' && !enabled && ( -

- Enable crypto trading in the Connection tab to configure guards. -

+ {step === 2 && type === 'ccxt' && ( +
+

Configure your connection

+ + setId(e.target.value.trim())} placeholder={defaultId} /> + + + setExchange(e.target.value.trim())} placeholder="binance" /> + + + + +
+ + +
+ {error &&

{error}

} +
+ )} + + {step === 2 && type === 'alpaca' && ( +
+

Configure your connection

+ + setId(e.target.value.trim())} placeholder={defaultId} /> + + +

When enabled, orders are routed to Alpaca's paper trading environment.

+ {error &&

{error}

} +
+ )} + + {step === 3 && ( +
+

API Credentials

+ + setApiKey(e.target.value)} placeholder="Optional — can be added later" /> + + + setApiSecret(e.target.value)} placeholder="Optional — can be added later" /> + + {type === 'ccxt' && ( + + setPassword(e.target.value)} placeholder="Required by some exchanges (e.g. OKX)" /> + )} + {error &&

{error}

}
)} - {loadError &&

Failed to load configuration.

}
-
+ + {/* Footer */} +
+ {step === 1 && ( + + )} + {step > 1 && ( + + )} + {step === 2 && ( + + )} + {step === 3 && ( + + )} +
+ ) } -// ==================== Exchange Section (CCXT-specific) ==================== +// ==================== Edit Dialog ==================== -interface ExchangeSectionProps { - config: CryptoConfig - onChange: (patch: Partial) => void - onChangeImmediate: (patch: Partial) => void -} +function EditDialog({ account, platform, onSaveAccount, onSavePlatform, onDelete, onClose }: { + account: AccountConfig + platform: PlatformConfig + onSaveAccount: (a: AccountConfig) => Promise + onSavePlatform: (p: PlatformConfig) => Promise + onDelete: () => Promise + onClose: () => void +}) { + const [accountDraft, setAccountDraft] = useState(account) + const [platformDraft, setPlatformDraft] = useState(platform) + const [saving, setSaving] = useState(false) + const [msg, setMsg] = useState('') + const [guardsOpen, setGuardsOpen] = useState(false) + + useEffect(() => { setAccountDraft(account) }, [account]) + useEffect(() => { setPlatformDraft(platform) }, [platform]) + + const dirty = + JSON.stringify(accountDraft) !== JSON.stringify(account) || + JSON.stringify(platformDraft) !== JSON.stringify(platform) + + const patchAccount = (field: keyof AccountConfig, value: unknown) => { + setAccountDraft((d) => ({ ...d, [field]: value })) + } -function ExchangeSection({ config, onChange, onChangeImmediate }: ExchangeSectionProps) { - const provider = config.provider + const patchPlatform = (field: string, value: unknown) => { + setPlatformDraft((d) => ({ ...d, [field]: value }) as PlatformConfig) + } - const patchProvider = (field: string, value: unknown, immediate: boolean) => { - const patch = { - provider: { ...provider, type: 'ccxt' as const, [field]: value }, + const handleSave = async () => { + setSaving(true); setMsg('') + try { + if (JSON.stringify(platformDraft) !== JSON.stringify(platform)) { + await onSavePlatform(platformDraft) + } + if (JSON.stringify(accountDraft) !== JSON.stringify(account)) { + await onSaveAccount(accountDraft) + } + setMsg('Saved') + setTimeout(() => setMsg(''), 2000) + } catch (err) { + setMsg(err instanceof Error ? err.message : 'Save failed') + } finally { + setSaving(false) } - immediate ? onChangeImmediate(patch) : onChange(patch) } + const guardTypes = platform.type === 'ccxt' ? CRYPTO_GUARD_TYPES : SECURITIES_GUARD_TYPES + return ( -
+ + {/* Header */} +
+

{account.id}

+ +
+ + {/* Body */} +
+ {/* Connection */} +
+
+ Type + + {platform.type === 'ccxt' ? 'CCXT' : 'Alpaca'} + +
+ {platformDraft.type === 'ccxt' ? ( + + ) : ( + + )} +
+ + {/* Credentials */} +
+ + patchAccount('apiKey', e.target.value)} placeholder="Not configured" /> + + + patchAccount('apiSecret', e.target.value)} placeholder="Not configured" /> + + {platform.type === 'ccxt' && ( + + patchAccount('password', e.target.value)} placeholder="Required by some exchanges (e.g. OKX)" /> + + )} +
+ + {/* Guards */} +
+ + {guardsOpen && ( +
+ patchAccount('guards', guards)} + onChangeImmediate={(guards) => patchAccount('guards', guards)} + /> +
+ )} +
+ + {/* Delete */} +
+ +
+
+ + {/* Footer */} +
+ {dirty && ( + + )} + {msg && {msg}} +
+ +
+
+ ) +} + +// ==================== Connection Fields ==================== + +function CcxtConnectionFields({ draft, onPatch }: { + draft: CcxtPlatformConfig + onPatch: (field: string, value: unknown) => void +}) { + return ( + <> - patchProvider('exchange', e.target.value.trim(), false)} - placeholder="bybit" - /> - - - patchProvider('apiKey', e.target.value, false)} - placeholder="Not configured" - /> - - - patchProvider('apiSecret', e.target.value, false)} - placeholder="Not configured" - /> - - - patchProvider('password', e.target.value, false)} - placeholder="Required by some exchanges (e.g. OKX)" - /> + onPatch('exchange', e.target.value.trim())} placeholder="binance" /> - onPatch('defaultMarketType', e.target.value)}> -
-
+ + ) +} + +function AlpacaConnectionFields({ draft, onPatch }: { + draft: AlpacaPlatformConfig + onPatch: (field: string, value: unknown) => void +}) { + return ( + <> + +

When enabled, orders are routed to Alpaca's paper trading environment.

+ + ) +} + +// ==================== Delete Button ==================== + +function DeleteButton({ label, onConfirm }: { label: string; onConfirm: () => void }) { + const [confirming, setConfirming] = useState(false) + + if (confirming) { + return ( +
+ + +
+ ) + } + + return ( + ) }