Scott Stanfield
Vertigo Software, Inc.
July 1999
Summary: Describes the design and implementation of the FMStocks business logic layer (BLL). (21 printed pages)
Overview
Class Breakdown
Account.cls
Broker.cls
Ticker.cls
About the Author
For More Information
The FMStocks business logic layer (or BLL for short) encapsulates all knowledge about Fitch & Mather's process of buying and selling stocks. It doesn't have any knowledge of the database—all database activity is hidden in the data access layer (DAL). See "Fitch & Mather Stocks: Data Access Layer." The BLL enlists the DAL to do the dirty work. Meanwhile, Active Server Pages (ASP) applications or stand-alone Microsoft® Windows® programs in turn can use the BLL directly.
In particular, the BLL handles the intricacies behind buying and selling stocks. For example, what should happen when an account makes a request to buy stock? Should the trade execute right away? What if there are not enough shares? These decisions, or business logic, are found in one place. If there are multiple clients (FMStocks has only one—the web site), then changes to the process can be made in a central location.
The BLL has four classes, as shown in Figure 1.
Figure 1. FMStocks_Bus classes
The Version class is for debugging purposes. It is described in detail in the FMStocks DAL document referred to above.
The other three classes each contain semantically related tasks: Account.cls deals with accounts, Ticker.cls handles stock details, and Broker.cls manages broker functions.
The three classes contain a total of 12 methods. Each method in the BLL can be categorized in one of three areas:
Occasionally a simple pass-through method gets "upgraded" to a logic-heavy method. This can happen if the business requirements change after the database is designed.
The Account class has 5 public methods:
Method | Type | Description |
VerifyLogin() | Pass-through | Used by the login web page to verify an e-mail password combination. |
ListPositionsForSale() | Pass-through | Returns a list of positions available for sale. Used by SellStock.asp. |
ListPositions() | Pass-through | Returns the positions owned by an account, suitable for display by Portfolio.asp. |
GetSummary() | Transformation | Converts the recordset returned by the DAL into output scalar parameters. Used by AccountSummary.asp. |
Add() | Logic | Creates a new account with a preset hard-coded balance of $10,000. |
VerifyLogin's structure is common to all pass-through methods. Because this is the first one in this document, I'll spend a little more time describing how it works.
The code is similar to its cousin in FMStocks_DB Ticker.cls. The difference is that this one does not use Microsoft ActiveX® Data Objects (ADO) to do the work—it uses FMStocks_DB.Ticker.
The first line sets up the error handler that diverts the flow of execution to the bottom of the method if any runtime errors or exceptions crop up. This exception handler cleans up objects constructed through the CtxCreateObject helper function.
Note Please read "Fitch & Mather Stocks: Data Access Layer" for a complete discussion of the Ctx* helper functions. Both FMStocks_DB and FMStocks_Bus share the same set of helper functions in the common file Context.bas.
The next two lines in VerifyLogin create an instance of FMStocks_DB.Ticker using a common MTS context. Then, the real work takes place in the call to the DAL method VerifyUser. For consistency, it should have been named VerifyLogin to match the Business Layer.
Next, the instance of the FMStocks_DB.Account object was forcibly cleaned up by setting it to nothing. If you forget this line, the object will be cleaned up when you leave the VerifyLogin method. However, in keeping with the Microsoft Transaction Server (MTS) resource-programming model, you want to free any acquired resource as soon as possible.
On the way out, signal to MTS that you are ready to be lobotomized via CtxSetComplete, then exit from the function. The Boolean return value was taken care of during the call to VerifyUser.
VerifyLogin is used by default.asp.
Public Function VerifyLogin(ByVal EMail As String, _
ByVal Password As String, _
ByRef FullName As Variant, _
ByRef AccountID As Variant) As Boolean
On Error GoTo errorHandler
VerifyLogin = False ' Default to *not* verified
Dim obj As FMStocks_DB.Account
Set obj = CtxCreateObject("FMStocks_DB.Account")
VerifyLogin = obj.VerifyUser(EMail, Password, AccountID, FullName)
Set obj = Nothing
CtxSetComplete
Exit Function
errorHandler:
Set obj = Nothing
CtxRaiseError g_modName,"VerifyLogin(" & EMail & "," & Password & _
")"
End Function
ListPositionsForSale returns a recordset of positions owned by a given account. If that account owns more than one "chunk" of stock in a particular company, these are summed up so they appear as a single purchase.
The recordset returns a list of tickers, the total shares owned for each ticker, and its current price. ListPositionsForSale is used by SellStock.asp.
Public Function ListPositionsForSale(ByVal AccountID As Long) As _
Recordset
On Error GoTo errorHandler
Dim obj As FMStocks_DB.Position
Set obj = CtxCreateObject("FMStocks_DB.Position")
Set ListPositionsForSale = obj.ListForSale(AccountID)
CtxSetComplete
Exit Function
errorHandler:
Set obj = Nothing
CtxRaiseError g_modName, "ListPositionsForSale(" & AccountID & ")"
End Function
Another name mismatch: ListPositions calls the DAL method ListSummary. However, it is similar to ListPositionsForSale in that it returns a list of tickers and shares owned for an account. ListPositionsForSale returns the current price for each symbol; ListPositions returns the average price paid for each share owned. The average price calculation takes into consideration the possibility of different commissions and strike prices.
The calculations in the underlying stored procedure, Position_ListSummary, are fairly complex. It is documented fully in FMStocks Database Design.
Public Function ListPositions(ByVal AccountID As Long) As Recordset
On Error GoTo errorHandler
Dim objDB As FMStocks_DB.Position
Set objDB = CtxCreateObject("FMStocks_DB.Position")
Set ListPositions = objDB.ListSummary(AccountID)
Set objDB = Nothing
CtxSetComplete
Exit Function
errorHandler:
Set objDB = Nothing
CtxRaiseError g_modName, "ListPositions(" & AccountID & ")"
End Function
GetSummary uses three output parameters to return high-level summary information about an account:
GetSummary typifies a transformation-style method. It extracts three fields from the one-row recordset returned by FMStocks_DB.Account.Summary, and returns them to the caller through ByRef parameters.
Warning If you are using server-side Microsoft JScript® 5.0 or earlier in your ASP code, it can't handle these ByRef, or output, parameters. This is the primary reason why we at Vertigo Software use Microsoft Visual Basic®, Scripting Edition (VBScript) programming language on the server.
The code also maps NULL values to zeros, using a helper function called ZeroNull. For accounts that do not own stocks, the underlying query returns a NULL for MarketValue.
Public Function GetSummary(ByVal AccountID As Long, _
ByRef MarketValue As Variant, _
ByRef TotalInvestment As Variant, _
ByRef CashBalance As Variant)
On Error GoTo errorHandler
Dim objAccount As FMStocks_DB.Account
Set objAccount = CtxCreateObject("FMStocks_DB.Account")
Dim rs As ADODB.Recordset
Set rs = objAccount.Summary(AccountID)
Set objAccount = Nothing
MarketValue = ZeroNull(rs("MarketValue").Value)
TotalInvestment = ZeroNull(rs("TotalInvestment").Value)
CashBalance = ZeroNull(rs("CashBalance").Value)
CtxSetComplete
Exit Function
errorHandler:
MarketValue = 0
TotalInvestment = 0
CashBalance = 0
Set rs = Nothing
Set objAccount = Nothing
CtxRaiseError g_modName, "GetSummary(" & AccountID & ")"
End Function
Private Function ZeroNull(ByVal x As Variant) As Currency
If IsNull(x) Then
ZeroNull = 0
Else
ZeroNull = x
End If
End Function
The Account.Add method creates new accounts using the four parameters and returns the new AccountID. The small bit of logic in this method that distinguishes it from a simple pass-through is the fact that it hard-codes the default opening balance. Fitch & Mather are swell folks: instead of giving away toasters, they offer new accounts a starting balance of $10,000.
Public Function Add(ByVal FirstName As String, _
ByVal LastName As String, _
ByVal Password As String, _
ByVal EMail As String) As Long
On Error GoTo errorHandler
Dim db As Object 'FMStocks_DB.Account
Set db = CtxCreateObject("FMStocks_DB.Account")
Dim defBalance As Currency
defBalance = "10000"
Add = db.Add(FirstName, LastName, Password, EMail, defBalance)
Set db = Nothing
CtxSetComplete
Exit Function
errorHandler:
Set db = Nothing
CtxRaiseError g_modName, "Add"
End Function
The Broker class has 3 public methods:
Method | Type | Description |
BuyStock() | Logic | Adds the buy order to the transaction table, then proceeds to execute the order using ExecuteTransaction. |
SellStock() | Logic | Adds the sell order to the transaction table, then executes it using ExecuteTransaction. |
ExecuteTransaction() | Logic | Although this method is marked public, it is only used by the two methods above. |
When a user enters a trade order to buy a particular stock, the BuyStock.asp logic uses BuyStock() to log and execute the trade. In order to fully understand BuyStock and SellStock, it is important to realize that logging a request to buy or sell some stock is different than the execution of that order.
The original intent of this architecture was that the web pages would log the buy and sell orders in the transaction table using BuyStock and SellStock. A separate "broker" Visual Basic program (Broker.exe) used a timer to periodically execute the next trade in line.
During performance testing, Broker was removed from the picture, and the trade right was executed after it was logged. The line in bold below was the only new line necessary to execute the trade after it was logged.
The AddBuyOrder is a DAL method that calls on the Tx_AddBuyOrder stored procedure to create a new entry in the transaction table. The real work occurs in ExecuteTransaction, which is discussed after SellStock.
' returns 0 on nothing to do, -1 on error, otherwise the new
' TransactionID
Public Function BuyStock(ByVal AccountID As Long, _
ByVal Ticker As String, _
ByVal Shares As Long) As Long
On Error GoTo errorHandler
Dim obj As FMStocks_DB.Tx
Set obj = CtxCreateObject("FMStocks_DB.Tx")
Dim txid As Long
' first log the transaction
txid = obj.AddBuyOrder(AccountID, Ticker, Shares)
Set obj = Nothing
BuyStock = ExecuteTransaction(txid) ' now execute the trade
CtxSetComplete
Exit Function
errorHandler:
Set obj = Nothing
CtxRaiseError g_modName, "BuyStock"
End Function
SellStock is the flip side of BuyStock. It logs the sell request to the Transaction table via the DAL Tx.AddSellOrder method. The line in bold uses the ExecuteTransaction method to do the actual work.
' returns 0 on nothing to do, -1 on error, otherwise the TxID
Public Function SellStock(ByVal AccountID As Long, _
ByVal Ticker As String, _
ByVal Shares As Long) As Long
On Error GoTo errorHandler
Dim obj As FMStocks_DB.Tx
Set obj = CtxCreateObject("FMStocks_DB.Tx")
Dim txid As Long
txid = obj.AddSellOrder(AccountID, Ticker, Shares)
Set obj = Nothing
SellStock = ExecuteTransaction(txid)
CtxSetComplete
Exit Function
errorHandler:
Set obj = Nothing
CtxRaiseError g_modName, "SellStock"
End Function
By far the most complicated business logic in all of FMStocks, ExecuteTransaction is responsible for executing a single buy or sell request. The only parameter it takes is the transaction ID (TxID). It returns –1 to indicate an error.
Note Before continuing, I need to make clear a possible source of confusion. My database schema includes a poorly named table called TRANSACTIONS. Please don't confuse this with the SQL concept of a "transaction" or the MTS concept of a "transaction context." The name ExecuteTransaction refers to a buy or sell request that was logged in the transaction TABLE.
The first half of ExecuteTransaction employs a few objects to gather information like the current price for the given ticker, and determines if TxID refers to a sell order or a buy order.
The second half is a big IF statement that splits up the Buy logic from the Sell logic. Both cases have special handling to handle the potential problem of a) not having enough money and b) trying to sell shares you don't really own.
It's fairly easy to detect the two conditions above. Temporarily overriding the error handling allows you to inspect the Err object directly. The error number for common scenarios can be coded for. Originally, I used a method in FMStocks_DB.Tx to set the transaction type to "insufficient funds" or "not enough shares." It wasn't until I started developing the business objects, however, that I realized I had created a scenario that required a little more knowledge about MTS and the way nested transactions work.
As it turns out, the trigger on the positions table can cause the Broker_Buy stored procedure to roll back. This is actually by design—I wanted the database to ensure that the account's balance never dips below zero. Enforcing this constraint in a trigger seemed a good place for that.
During the business tier processing of a particular buy request, insufficient funds can cause the entire transaction to roll back. I was catching the rollback in the processing logic and using the SetTxType to flag the purchase request as "Insufficient Funds." However, that change to the database was also rolled back!
Because the call to SetTxType didn't occur in its own transaction context, it was caught up in the big rollback initiated by the trigger. In other words, once one of the components votes to abort a transaction, everything in that transaction's context is rolled back.
Solving this problem was easy: I created a new class tagged as Requires a New Transaction, so it always executes independently of any other MTS context.
Figure 2 shows the error condition states, and their TransactionTypeID, in red. Coming into this function, a TransactionID must be in either state 1 or 2.
Figure 2. Transaction record states
This is the only occurrence of nested transactions. The complete source code is shown here:
' Used by ExecuteTrade. Also called directly from BuyStock above
' returns 0 on nothing to do, -1 on error, otherwise the TxID
Public Function ExecuteTransaction(ByVal txid As Long) As Long
On Error GoTo errorHandler
Dim rs As Recordset
Dim objTx As FMStocks_DB.Tx
Dim objTicker As FMStocks_DB.Ticker
Dim objBroker As FMStocks_DB.Broker
Dim AccountID As Long
Dim Ticker As String
Set objTicker = CtxCreateObject("FMStocks_DB.Ticker")
Set objTx = CtxCreateObject("FMStocks_DB.Tx")
Set objBroker = CtxCreateObject("FMStocks_DB.Broker")
' Get the Tx details
Set rs = objTx.GetByID(txid)
If (rs.EOF And rs.BOF) Then Exit Function
AccountID = rs("AccountID")
Ticker = RTrim(rs("Ticker").Value)
' Only execute buy or sell orders!
If rs("TransactionTypeID") <> 1 And rs("TransactionTypeID") <> 2 Then
ExecuteTransaction = 0
CtxSetComplete
Exit Function
End If
'Get the current price
Dim price As Currency
price = objTicker.GetPrice(Ticker) ' price in pennies
'Execute the trade
Dim positionid As Long
Dim o As FMStocks_DB.TxNew
Dim lErr As Long
If rs("TransactionTypeID") = 1 Then
' Execute Buy order
On Error Resume Next
positionid = objBroker.Buy(txid, AccountID, Ticker, _
rs("shares"), price, g_commission)
lErr = Err.Number
' the next line resets Err.Number to 0, so we need to cache it
On Error GoTo errorHandler
If positionid = 0 And lErr = g_errInsufficentFunds Then
Err.Clear
' It's common that the user doesn't have enough funds.
' Set the TransactionTypeID = 5 indicating insufficient funds
' This guy runs as a separate transaction
Set o = CtxCreateObject("FMStocks_DB.TxNew")
o.SetTxType txid, 5
Set o = Nothing
ExecuteTransaction = 0 ' Signify that we handled this error
ElseIf positionid = 0 Then
' some other error was thrown that we can't handle
GoTo errorHandler
Else
ExecuteTransaction = txid ' The trade was handled
End If
Else
' Execute Sell order
Dim retval As Boolean
On Error Resume Next
retval = objBroker.Sell(txid, price, g_commission)
lErr = Err.Number ' the next line resets Err.Number to 0, so we
' need to cache it
On Error GoTo errorHandler
If retval = False Then
Err.Clear
' This runs as a separate transaction
Set o = CtxCreateObject("FMStocks_DB.TxNew")
' User tried to sell shares they didn't own
o.SetTxType txid, 6
Set o = Nothing
ExecuteTransaction = 0 ' Signify that we handled this error
Else
' We're responsible for removing the appropriate shares from
' their positions
AdjustPositions AccountID, Ticker, rs("shares")
ExecuteTransaction = txid
End If
End If
CtxSetComplete
Exit Function
errorHandler:
ExecuteTransaction = -1
Set objTx = Nothing
Set objTicker = Nothing
Set objBroker = Nothing
CtxRaiseError g_modName, "ExecuteTransaction"
End Function
If a sell request is successful, you have to adjust entries in the positions table. Because the positions table holds multiple entries for a given account and ticker, it is tricky to decrement the quantity owned of a particular ticker.
AdjustPositions is best illustrated by an example. Assume you bought Microsoft stock (MSFT) in two batches, say 5 shares and 10 shares. Both purchases exist in the positions table as shown in Figure 3.
Figure 3. Positions table before selling 7 shares
Now say you want to sell 7 shares. What should become of positions #30016 and #10101? The algorithm below uses FMStocks_DB.Position's ListForAdjustment method to return a connected, updateable recordset that you walk through and modify to result in the records shown in Figure 4.
Figure 4. After selling 7 shares
The most recently purchased set of shares was decremented by the number of shares requested to be sold. So the second purchase of 10 shares was decremented by 7, resulting in 3 shares.
Now if you sell 6 shares, PositionID #30016 can be deleted, and #10101 will be lowered to 2, as shown in Figure 5.
Figure 5. Selling 6 more shares
If you were to sell the remaining 2 shares, position #10101 would be deleted.
The do loop in the code below walks through each record in the list, trying to reconcile the number of shares to be sold with how many currently exist. The code will either delete an entire record, or decrement a record's current share amount. At the end of the loop, the recordset changes are committed via UpdateBatch.
This method shows how thin the wall is between the BLL and the DAL. Although the recordset originates from the DAL, it is modified and updated via the BLL. AdjustPositions is the only method in FMStocks that uses updateable recordsets.
Private Sub AdjustPositions(ByVal AccountID As Long, ByVal Ticker As _
String, ByVal Shares As Long)
Dim objPosition As FMStocks_DB.Position
Dim rs As ADODB.Recordset
Dim posShares As Long ' number of shares for the current position
Set objPosition = CtxCreateObject("FMStocks_DB.Position")
Set rs = objPosition.ListForAdjustment(AccountID, Ticker)
Do While (Not (rs.EOF) And Shares > 0)
posShares = rs("shares")
If posShares <= Shares Then
' we can delete this whole record
rs.Delete
Shares = Shares - posShares
Else
' decrement current share amount by amount sold
rs("shares").Value = posShares – Shares
Shares = 0
End If
rs.MoveNext
Loop
rs.UpdateBatch
rs.Close
Set rs = Nothing
End Sub
The Ticker class has 4 public methods:
Method | Type | Description |
VerifySymbol | Pass-Through | Returns true if the stock symbol exists in the database and can be purchased. |
ListByTicker | Pass-Through | Returns a list of stock symbols that begin with the letters passed as the first argument. |
ListyByCompany | Pass-Through | Returns a list of companies whose names begin with the letters passed as the first argument. |
GetFundamentals | Transformation/Logic | Applies a simple stock-screening algorithm to a given stock. |
VerifySymbol returns true if the symbol was located in the database. Used by BuyStock.asp.
Public Function VerifySymbol(ByVal Ticker As String) As Boolean
On Error GoTo errorHandler
Dim obj As FMStocks_DB.Ticker
Set obj = CtxCreateObject("FMStocks_DB.Ticker")
VerifySymbol = obj.VerifySymbol(Ticker)
Set obj = Nothing
CtxSetComplete
Exit Function
errorHandler:
Set obj = Nothing
CtxRaiseError g_modName, "VerifySymbol"
End Function
ListByTicker returns a recordset of company names and ticker symbols for all tickers that start with the characters in the first parameter.
Public Function ListByTicker(ByVal Ticker As String) As ADODB.Recordset
On Error GoTo errorHandler
Dim obj As FMStocks_DB.Ticker
Set obj = CtxCreateObject("FMStocks_DB.Ticker")
Set ListByTicker = obj.ListByTicker(Ticker)
Set obj = Nothing
CtxSetComplete
Exit Function
errorHandler:
Set obj = Nothing
CtxRaiseError g_modName, "ListByTicker"
End Function
ListByCompany returns a recordset of company names and ticker symbols for all companies whose name matches the first parameter.
' ListByCompany
'
Public Function ListByCompany(ByVal company As String) As ADODB.Recordset
On Error GoTo errorHandler
Dim obj As FMStocks_DB.Ticker
Set obj = CtxCreateObject("FMStocks_DB.Ticker")
Set ListByCompany = obj.ListByCompany(company)
Set obj = Nothing
CtxSetComplete
Exit Function
errorHandler:
Set obj = Nothing
CtxRaiseError g_modName, "ListByCompany"
End Function
The GetFundamentals function returns a report on a stock's fundamental data in the form of a recordset. Fundamentals refer to a stocks basic quarterly and yearly performance data. The DAL returns a "wide" recordset of one row; the logic below pivots the data into multiple rows with short and long descriptions of each data point. More importantly, each fundamental is tested against a hard-coded criterion that determines if a given stock meets the stringent requirements for a small-cap growth stock.
Visit The Motley Fool's web site at www.fool.com/, and click on the big button labeled Foolish Eight Stock Screener for details on each criteria.
This method differs from all the others in that it creates its own ADO Recordset object and populates it "by hand." The trick to creating a recordset is to open it with the adLockBatchOptimistic flag, and call UpdateBatch when you've finished building it, otherwise you won't be allowed to save your changes. One more tip: reset the recordset to the first item by calling MoveFirst.
Public Function GetFundamentals(ByVal Ticker As String, ByRef company _
As Variant, ByRef Exchange As Variant) As ADODB.Recordset
On Error GoTo errorHandler
Dim obj As FMStocks_DB.Ticker
Set obj = CtxCreateObject("FMStocks_DB.Ticker")
Dim rs As ADODB.Recordset
Set rs = obj.GetFundamentals(Ticker)
' Construct a new recordset based on the results from the fundamental
' queries
Dim rsnew As New ADODB.Recordset
rsnew.CursorLocation = adUseClient
'Add Some Fields
rsnew.Fields.Append "ShortDesc", adVarChar, 50
rsnew.Fields.Append "LongDesc", adVarChar, 100
rsnew.Fields.Append "Criteria", adVarChar, 50
rsnew.Fields.Append "Value", adVarChar, 50
rsnew.Fields.Append "Status", adInteger, 4
rsnew.Open , , adOpenStatic, adLockBatchOptimistic
Do While Not (rs.EOF)
company = rs("Company")
Exchange = rs("Exchange")
FormatItem rsnew, ZeroNull(rs("MarketCap")) < 1000, _
"MarketCap", "$" & ZeroNull(rs("MarketCap")) & "M", _
" < $1B", "Total market capitalization less than $1 Billion."
FormatItem rsnew, ZeroNull(rs("Sales")) < 100, "Sales", "$" & _
ZeroNull(rs("Sales")) & "M", "< $100M", "Gross revenue" & _
" (total sales) less than $100 Million."
FormatItem rsnew, ZeroNull(rs("Price")) > 7, "Price", "$" & _
ZeroNull(rs("Price")), "> $7/share", "Stock price" & _
" greater than $7 per share."
FormatItem rsnew, ZeroNull(rs("DailyDollarVol")) > 1, "Daily" & _
" dollar volume", "$" & ZeroNull(rs("DailyDollarVol")) & _
"M", "> $1M per day", "At least $100M in shares move on" & _
" an average day."
FormatItem rsnew, ZeroNull(rs("SalesGrowth")) > 25, "Sales" & _
" growth", ZeroNull(rs("SalesGrowth")) & "%", "> 25%", _
"Gross revenue growth of 25% trailing-twelve months (TTM)."
FormatItem rsnew, ZeroNull(rs("EPSGrowth")) > 25, "Earnings" & _
" growth", ZeroNull(rs("EPSGrowth")) & "%", "> 25%", _
"Earnings-per-share growth over TTM."
FormatItem rsnew, ZeroNull(rs("NetProfitMargin")) > 7, _
"Profit margin", ZeroNull(rs("NetProfitMargin")) & "%", _
"> 7%", "Net profit margin at least 7%."
FormatItem rsnew, ZeroNull(rs("InsiderShares")) > 10, _
"Insider holdings", ZeroNull(rs("InsiderShares")) & "%", _
"> 10%", "At least 10% of the shares should be held by" & _
" insiders."
rs.MoveNext
Loop
rsnew.UpdateBatch adAffectAll
rsnew.MoveFirst
Set obj = Nothing
Set rs = Nothing
Set GetFundamentals = rsnew
CtxSetComplete
Exit Function
errorHandler:
obj = Nothing
CtxRaiseError g_modName, "GetFundamentals(" & Ticker & ")"
End Function
Sub FormatItem(ByRef rs As ADODB.Recordset, Status As Integer, _
ShortDesc As String, Value As String, _
Criteria As String, LongDesc As String)
rs.AddNew Array("ShortDesc", "LongDesc", "Criteria", "Value", _
"Status"), _
Array(ShortDesc, LongDesc, Criteria, Value, Status)
End Sub
Scott Stanfield is the president of Vertigo Software, Inc. Vertigo Software is a San Francisco Bay Area-based consulting firm that specializes in the design and development of Windows Distributed interNet Architecture (DNA) applications and components. He can be reached at scott@vertigosoftware.com or on the Web at http://www.vertigosoftware.com/.